diff --git a/.github/workflows/api-ci.yml b/.github/workflows/api-ci.yml index d3acf94..9f4b975 100644 --- a/.github/workflows/api-ci.yml +++ b/.github/workflows/api-ci.yml @@ -64,14 +64,23 @@ jobs: - name: Install dependencies run: uv sync --extra dev - - name: Run tests - run: uv run pytest tests/ -v + - name: Run tests with coverage + run: uv run pytest tests/ -v --cov=src/finquest_api --cov-report=term --cov-report=term-missing --cov-report=html --cov-report=json env: SUPABASE_URL: ${{ secrets.SUPABASE_URL }} SUPABASE_KEY: ${{ secrets.SUPABASE_KEY }} SUPABASE_DB_URL: ${{ secrets.SUPABASE_DB_URL }} SUPABASE_JWT_SECRET: ${{ secrets.SUPABASE_JWT_SECRET }} + - name: Upload coverage reports + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: | + app/services/api/htmlcov/ + app/services/api/coverage.json + retention-days: 30 + - name: Test API startup run: | uv run python -c "from finquest_api.main import app; print('API imports successfully')" diff --git a/app/services/api/README.md b/app/services/api/README.md index 9fb18c5..6010ce8 100644 --- a/app/services/api/README.md +++ b/app/services/api/README.md @@ -101,13 +101,26 @@ uv add ## Testing ```bash -# Install test dependencies -uv add --dev pytest pytest-asyncio httpx +# Install test dependencies (includes pytest-cov for coverage) +uv sync --group dev # Run tests uv run pytest + +# Run tests with coverage report (includes all files, even 100% covered) +# Note: Files with 100% coverage ARE included in the total percentage calculation, +# but may be hidden from the detailed report. To see all files, use: +uv run pytest --cov=src/finquest_api --cov-report=term --cov-report=term-missing --cov-report=html + +# Run tests with coverage summary only (skips 100% covered files for brevity) +uv run pytest --cov=src/finquest_api --cov-report=term-missing:skip-covered ``` +Coverage reports: + +- Terminal output: Shows coverage percentage and missing lines +- HTML report: Generated in `htmlcov/index.html` (open in browser for detailed view) + ## Next Steps - [ ] Add database integration (PostgreSQL/SQLite) diff --git a/app/services/api/migrations/001_add_gamification_tables.sql b/app/services/api/migrations/001_add_gamification_tables.sql new file mode 100644 index 0000000..7bf96d3 --- /dev/null +++ b/app/services/api/migrations/001_add_gamification_tables.sql @@ -0,0 +1,63 @@ +-- Migration: Add gamification tables +-- Created: 2025-01-XX +-- Description: Adds user_gamification_stats, badge_definitions, and user_badges tables + +-- Create user_gamification_stats table +CREATE TABLE IF NOT EXISTS user_gamification_stats ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL UNIQUE REFERENCES users (id) ON DELETE CASCADE, + total_xp INTEGER NOT NULL DEFAULT 0, + level INTEGER NOT NULL DEFAULT 1, + current_streak INTEGER NOT NULL DEFAULT 0, + last_streak_date DATE, + total_modules_completed INTEGER NOT NULL DEFAULT 0, + total_quizzes_completed INTEGER NOT NULL DEFAULT 0, + total_portfolio_positions INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_gamification_stats_user ON user_gamification_stats (user_id); +CREATE INDEX IF NOT EXISTS ix_gamification_stats_user ON user_gamification_stats (user_id); + +-- Create badge_definitions table +CREATE TABLE IF NOT EXISTS badge_definitions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + code TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + description TEXT NOT NULL, + category TEXT NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_badge_code ON badge_definitions (code); +CREATE INDEX IF NOT EXISTS ix_badge_code ON badge_definitions (code); + +-- Create user_badges table +CREATE TABLE IF NOT EXISTS user_badges ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE, + badge_id UUID NOT NULL REFERENCES badge_definitions (id) ON DELETE CASCADE, + earned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (user_id, badge_id) +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_user_badge ON user_badges (user_id, badge_id); +CREATE INDEX IF NOT EXISTS ix_user_badges_user ON user_badges (user_id); +CREATE INDEX IF NOT EXISTS ix_user_badges_badge ON user_badges (badge_id); + +-- Add trigger to update updated_at timestamp +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ language 'plpgsql'; + +CREATE TRIGGER update_user_gamification_stats_updated_at + BEFORE UPDATE ON user_gamification_stats + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + diff --git a/app/services/api/pyproject.toml b/app/services/api/pyproject.toml index 8d03da8..0fef9ca 100644 --- a/app/services/api/pyproject.toml +++ b/app/services/api/pyproject.toml @@ -37,4 +37,31 @@ build-backend = "uv_build" [dependency-groups] dev = [ "pytest>=8.4.2", + "pytest-cov>=6.0.0", +] + +[tool.coverage.run] +source = ["src"] +omit = [ + "*/tests/*", + "*/test_*.py", + "*/__pycache__/*", + "*/migrations/*", + "*/scripts/*", + "*/examples/*", +] + +[tool.coverage.report] +precision = 2 +show_missing = true +skip_covered = false +skip_empty = false +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "raise AssertionError", + "raise NotImplementedError", + "if __name__ == .__main__.:", + "if TYPE_CHECKING:", + "@abstractmethod", ] diff --git a/app/services/api/pytest.ini b/app/services/api/pytest.ini index 9855d94..e1b6ed6 100644 --- a/app/services/api/pytest.ini +++ b/app/services/api/pytest.ini @@ -3,4 +3,4 @@ testpaths = tests python_files = test_*.py python_classes = Test* python_functions = test_* -addopts = -v --tb=short +addopts = -v --tb=short --cov-report=term-missing --cov-report=html diff --git a/app/services/api/scripts/seed_badges.py b/app/services/api/scripts/seed_badges.py new file mode 100644 index 0000000..c367737 --- /dev/null +++ b/app/services/api/scripts/seed_badges.py @@ -0,0 +1,176 @@ +""" +Seed badge definitions into the database. +""" +import sys +from pathlib import Path + +# Add parent directory to path +sys.path.insert(0, str(Path(__file__).parent.parent)) + +# Load environment variables before importing database modules +# Note: pydantic-settings will also load .env automatically, but we try dotenv first +try: + from dotenv import load_dotenv + env_path = Path(__file__).parent.parent / '.env' + if env_path.exists(): + load_dotenv(env_path) + print(f"Loaded environment from {env_path}") + else: + print(f"Note: .env file not found at {env_path}") + print("Using environment variables or pydantic-settings will load .env") +except ImportError: + print("Note: python-dotenv not available, relying on pydantic-settings for .env loading") + +print("Importing database modules...") +try: + from sqlalchemy.orm import Session + from finquest_api.db.session import get_engine, SessionLocal + from finquest_api.db.models import BadgeDefinition + print("Database modules imported successfully") +except Exception as e: + print(f"❌ Error importing database modules: {e}") + print("This might be due to missing SUPABASE_DB_URL environment variable") + import traceback + traceback.print_exc() + sys.exit(1) + + +BADGE_DEFINITIONS = [ + # Learning badges + { + "code": "MODULE_5", + "name": "Module Apprentice", + "description": "Completed 5 modules", + "category": "learning", + "is_active": True, + }, + { + "code": "MODULE_10", + "name": "Module Scholar", + "description": "Completed 10 modules", + "category": "learning", + "is_active": True, + }, + { + "code": "MODULE_20", + "name": "Module Master", + "description": "Completed 20 modules", + "category": "learning", + "is_active": True, + }, + { + "code": "QUIZ_CHAMP", + "name": "Quiz Champ", + "description": "Achieved ≥90% on any quiz", + "category": "learning", + "is_active": True, + }, + # Note: Category Finisher badges would need category tracking - skipping for MVP + + # Portfolio badges + { + "code": "PORTFOLIO_CREATOR", + "name": "Portfolio Creator", + "description": "Added first portfolio position", + "category": "portfolio", + "is_active": True, + }, + { + "code": "DIVERSIFIER", + "name": "Diversifier", + "description": "Have 3 or more distinct positions", + "category": "portfolio", + "is_active": True, + }, + { + "code": "RISK_MANAGER", + "name": "Risk Manager", + "description": "Performed a rebalance action", + "category": "portfolio", + "is_active": False, # Not implemented yet + }, + { + "code": "LONG_TERM_THINKER", + "name": "Long-Term Thinker", + "description": "Kept a position for at least 30 days", + "category": "portfolio", + "is_active": False, # Not implemented yet + }, + { + "code": "ANALYST", + "name": "Analyst", + "description": "Completed 5 stock analysis actions", + "category": "portfolio", + "is_active": False, # Not implemented yet + }, + + # Streak badges + { + "code": "STREAK_7", + "name": "7-Day Streak", + "description": "Reached a 7-day streak", + "category": "streak", + "is_active": True, + }, + { + "code": "STREAK_30", + "name": "30-Day Streak", + "description": "Reached a 30-day streak", + "category": "streak", + "is_active": True, + }, +] + + +def seed_badges(): + """Seed badge definitions into the database.""" + try: + print("Getting database engine...") + engine = get_engine() + print("Creating database session...") + db: Session = SessionLocal(bind=engine) + + print(f"Seeding {len(BADGE_DEFINITIONS)} badge definitions...") + added_count = 0 + skipped_count = 0 + + for badge_data in BADGE_DEFINITIONS: + existing = db.query(BadgeDefinition).filter( + BadgeDefinition.code == badge_data["code"] + ).first() + + if existing: + print(f" ⏭️ Badge {badge_data['code']} already exists, skipping...") + skipped_count += 1 + continue + + badge = BadgeDefinition(**badge_data) + db.add(badge) + print(f" ✅ Added badge: {badge_data['code']} - {badge_data['name']}") + added_count += 1 + + db.commit() + print("\n✅ Successfully seeded badge definitions!") + print(f" Added: {added_count}, Skipped: {skipped_count}") + + except RuntimeError as e: + if "SUPABASE_DB_URL" in str(e): + print(f"\n❌ Database configuration error: {e}") + print("\nPlease set SUPABASE_DB_URL in your environment or .env file") + sys.exit(1) + raise + except Exception as e: + if db: + db.rollback() + print(f"\n❌ Error seeding badges: {e}") + import traceback + traceback.print_exc() + raise + finally: + if 'db' in locals(): + db.close() + + +if __name__ == "__main__": + seed_badges() + diff --git a/app/services/api/src/finquest_api/db/models.py b/app/services/api/src/finquest_api/db/models.py index 1d89510..9ff0043 100644 --- a/app/services/api/src/finquest_api/db/models.py +++ b/app/services/api/src/finquest_api/db/models.py @@ -492,3 +492,56 @@ class DailyLearningLog(Base): created_at: Mapped[datetime] = ts_created() updated_at: Mapped[datetime] = ts_updated() + +class UserGamificationStats(Base): + __tablename__ = "user_gamification_stats" + __table_args__ = (UniqueConstraint("user_id", name="uq_gamification_stats_user"),) + + id: Mapped[UUID] = uuid_pk() + user_id: Mapped[UUID] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + unique=True, + index=True + ) + total_xp: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + level: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + current_streak: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + last_streak_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True) + total_modules_completed: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + total_quizzes_completed: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + total_portfolio_positions: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + created_at: Mapped[datetime] = ts_created() + updated_at: Mapped[datetime] = ts_updated() + + +class BadgeDefinition(Base): + __tablename__ = "badge_definitions" + __table_args__ = (UniqueConstraint("code", name="uq_badge_code"),) + + id: Mapped[UUID] = uuid_pk() + code: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True) + name: Mapped[str] = mapped_column(String(120), nullable=False) + description: Mapped[str] = mapped_column(Text, nullable=False) + category: Mapped[str] = mapped_column(String(32), nullable=False) # 'learning', 'streak', 'portfolio' + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + created_at: Mapped[datetime] = ts_created() + + +class UserBadge(Base): + __tablename__ = "user_badges" + __table_args__ = (UniqueConstraint("user_id", "badge_id", name="uq_user_badge"),) + + id: Mapped[UUID] = uuid_pk() + user_id: Mapped[UUID] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + index=True + ) + badge_id: Mapped[UUID] = mapped_column( + ForeignKey("badge_definitions.id", ondelete="CASCADE"), + nullable=False, + index=True + ) + earned_at: Mapped[datetime] = ts_created() + diff --git a/app/services/api/src/finquest_api/main.py b/app/services/api/src/finquest_api/main.py index b9b0f67..985f764 100644 --- a/app/services/api/src/finquest_api/main.py +++ b/app/services/api/src/finquest_api/main.py @@ -5,7 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware from .routers import health, api, auth from .config import settings -from .routers import portfolio, users, modules +from .routers import portfolio, users, modules, gamification # Create FastAPI app instance app = FastAPI( @@ -35,6 +35,9 @@ # Include portfolio router app.include_router(portfolio.router, prefix="/api", tags=["portfolio"]) +# Include gamification router +app.include_router(gamification.router, prefix="/api/gamification", tags=["gamification"]) + @app.get("/") async def root(): diff --git a/app/services/api/src/finquest_api/routers/gamification.py b/app/services/api/src/finquest_api/routers/gamification.py new file mode 100644 index 0000000..ec0a744 --- /dev/null +++ b/app/services/api/src/finquest_api/routers/gamification.py @@ -0,0 +1,236 @@ +""" +Gamification API endpoints. +""" +from datetime import date, datetime +from typing import Optional +from uuid import UUID + +from fastapi import APIRouter, Depends +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from ..auth_utils import get_current_user +from ..db.models import User, BadgeDefinition, UserBadge +from ..db.session import get_session +from ..services.gamification import ( + get_or_create_stats, + compute_level, + get_xp_to_next_level, + update_streak, + evaluate_badges, + check_module_first_time, + get_portfolio_position_count, + XP_REWARDS, +) + +router = APIRouter() + + +# Request/Response Models +class GamificationEventRequest(BaseModel): + event_type: str # "login", "module_completed", "quiz_completed", "portfolio_position_added", "portfolio_position_updated" + module_id: Optional[str] = None + quiz_score: Optional[float] = None + quiz_completed_at: Optional[str] = None # ISO datetime string + portfolio_position_id: Optional[str] = None + is_first_time_for_module: Optional[bool] = None + + +class BadgeInfo(BaseModel): + code: str + name: str + description: str + + +class GamificationEventResponse(BaseModel): + total_xp: int + level: int + current_streak: int + xp_gained: int + level_up: bool + streak_incremented: bool + new_badges: list[BadgeInfo] + xp_to_next_level: int + + +class GamificationStateResponse(BaseModel): + total_xp: int + level: int + current_streak: int + xp_to_next_level: int + badges: list[BadgeInfo] + + +class BadgeDefinitionResponse(BaseModel): + code: str + name: str + description: str + category: str + is_active: bool + earned: bool + + +@router.post("/event", response_model=GamificationEventResponse) +async def handle_gamification_event( + event: GamificationEventRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_session), +): + """ + Handle a gamification event and update user stats. + """ + stats = get_or_create_stats(db, current_user.id) + + xp_gained = 0 + streak_incremented = False + previous_level = stats.level + previous_streak = stats.current_streak + + # Parse quiz date if provided + quiz_date: Optional[date] = None + if event.quiz_completed_at: + try: + quiz_datetime = datetime.fromisoformat(event.quiz_completed_at.replace("Z", "+00:00")) + quiz_date = quiz_datetime.date() + except Exception: + quiz_date = datetime.utcnow().date() + + # Handle different event types + if event.event_type == "login": + xp_gained += XP_REWARDS["login"] + + elif event.event_type == "module_completed": + xp_gained += XP_REWARDS["module_completed"] + stats.total_modules_completed += 1 + + # Check if first time (if module_id provided) + if event.module_id and event.is_first_time_for_module is None: + try: + module_uuid = UUID(event.module_id) + is_first_time = check_module_first_time(db, current_user.id, module_uuid) + except (ValueError, Exception): + is_first_time = False + else: + is_first_time = event.is_first_time_for_module or False + + if is_first_time: + xp_gained += XP_REWARDS["module_completed_first_time"] + + elif event.event_type == "quiz_completed": + # Only award XP and update stats if quiz was passed (score >= 70%) + if event.quiz_score is not None and event.quiz_score >= 70: + stats.total_quizzes_completed += 1 + + if event.quiz_score >= 80: + xp_gained += XP_REWARDS["quiz_completed_high"] + else: + xp_gained += XP_REWARDS["quiz_completed_low"] + + # Update streak only for passed quizzes + if quiz_date is None: + quiz_date = datetime.utcnow().date() + + streak_incremented = update_streak(db, stats, quiz_date) + # Only count as incremented if streak actually increased + streak_incremented = streak_incremented and stats.current_streak > previous_streak + if streak_incremented: + xp_gained += XP_REWARDS["streak_bonus"] + + elif event.event_type == "portfolio_position_added": + xp_gained += XP_REWARDS["portfolio_position_added"] + # Update position count + stats.total_portfolio_positions = get_portfolio_position_count(db, current_user.id) + + elif event.event_type == "portfolio_position_updated": + xp_gained += XP_REWARDS["portfolio_position_updated"] + + # Apply XP and level + stats.total_xp += xp_gained + stats.level = compute_level(stats.total_xp) + level_up = stats.level > previous_level + + # Evaluate badges + new_badges = evaluate_badges(db, current_user.id, stats) + + # Save changes + db.commit() + db.refresh(stats) + + xp_to_next = get_xp_to_next_level(stats.total_xp, stats.level) + + return GamificationEventResponse( + total_xp=stats.total_xp, + level=stats.level, + current_streak=stats.current_streak, + xp_gained=xp_gained, + level_up=level_up, + streak_incremented=streak_incremented, + new_badges=[BadgeInfo(**badge) for badge in new_badges], + xp_to_next_level=xp_to_next, + ) + + +@router.get("/me", response_model=GamificationStateResponse) +async def get_gamification_state( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_session), +): + """ + Get current gamification state for logged-in user. + """ + stats = get_or_create_stats(db, current_user.id) + + xp_to_next = get_xp_to_next_level(stats.total_xp, stats.level) + + # Get user's badges + user_badges = db.query(BadgeDefinition).join(UserBadge).filter( + UserBadge.user_id == current_user.id + ).all() + + badges = [ + BadgeInfo( + code=badge.code, + name=badge.name, + description=badge.description, + ) + for badge in user_badges + ] + + return GamificationStateResponse( + total_xp=stats.total_xp, + level=stats.level, + current_streak=stats.current_streak, + xp_to_next_level=xp_to_next, + badges=badges, + ) + + +@router.get("/badges", response_model=list[BadgeDefinitionResponse]) +async def get_all_badges( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_session), +): + """ + Get all badge definitions and which are earned by the user. + """ + all_badges = db.query(BadgeDefinition).all() + + # Get user's earned badge IDs + earned_badge_ids = { + badge_id for badge_id, in db.query(UserBadge.badge_id).filter( + UserBadge.user_id == current_user.id + ).all() + } + + return [ + BadgeDefinitionResponse( + code=badge.code, + name=badge.name, + description=badge.description, + category=badge.category, + is_active=badge.is_active, + earned=badge.id in earned_badge_ids, + ) + for badge in all_badges + ] + diff --git a/app/services/api/src/finquest_api/routers/modules.py b/app/services/api/src/finquest_api/routers/modules.py index 5386cfb..a1786ce 100644 --- a/app/services/api/src/finquest_api/routers/modules.py +++ b/app/services/api/src/finquest_api/routers/modules.py @@ -2,11 +2,11 @@ Learning modules endpoints """ from uuid import UUID -from fastapi import APIRouter, HTTPException, status, Depends +from fastapi import APIRouter, HTTPException, status, Depends, BackgroundTasks from sqlalchemy.orm import Session from ..auth_utils import get_current_user from ..db.models import User, Module, ModuleVersion, ModuleQuestion, ModuleChoice, ModuleAttempt, ModuleCompletion, Suggestion -from ..db.session import get_session +from ..db.session import get_session, SessionLocal, get_engine from ..schemas import ( ModuleContent, ModuleQuestion as SchemaModuleQuestion, @@ -14,16 +14,43 @@ ModuleAttemptRequest, ModuleAttemptResponse ) +from ..services.llm.service import LLMService +from ..services.module_generator import ModuleGenerator +from ..services.suggestion_generator import SuggestionGenerator +from ..config import settings router = APIRouter() +# Dependency for SuggestionGenerator +def get_suggestion_generator(): + llm_service = LLMService(settings.llm) + module_generator = ModuleGenerator(llm_service) + return SuggestionGenerator(llm_service, module_generator) + +async def generate_suggestions_task( + suggestion_generator: SuggestionGenerator, + user_id: str +): + """Background task to generate suggestions""" + db = SessionLocal(bind=get_engine()) + try: + user = db.query(User).filter(User.id == user_id).first() + if user: + await suggestion_generator.generate_suggestions_for_user(db, user) + except Exception as e: + print(f"Error generating suggestions in background: {e}") + finally: + db.close() + @router.post("/{module_id}/attempt", response_model=ModuleAttemptResponse) async def submit_module_attempt( module_id: str, attempt: ModuleAttemptRequest, + background_tasks: BackgroundTasks, user: User = Depends(get_current_user), db: Session = Depends(get_session), + suggestion_generator: SuggestionGenerator = Depends(get_suggestion_generator), ): """ Record a quiz attempt and mark module as completed if passed. @@ -71,6 +98,27 @@ async def submit_module_attempt( if suggestion: suggestion.status = "completed" db.add(suggestion) + + # Check if all existing modules are now completed + # If so, trigger generation of more modules + all_suggestions = db.query(Suggestion).filter( + Suggestion.user_id == user.id, + Suggestion.status.in_(["shown", "completed"]) + ).all() + + # Check if all suggestions are completed + all_completed = all( + s.status == "completed" + for s in all_suggestions + ) if all_suggestions else False + + # If all modules are completed, generate more + if all_completed and len(all_suggestions) > 0: + background_tasks.add_task( + generate_suggestions_task, + suggestion_generator, + str(user.id) + ) db.commit() @@ -154,6 +202,8 @@ async def get_module( questions=schema_questions ) + except HTTPException: + raise except ValueError: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, diff --git a/app/services/api/src/finquest_api/routers/portfolio.py b/app/services/api/src/finquest_api/routers/portfolio.py index c375d90..ec6ce62 100644 --- a/app/services/api/src/finquest_api/routers/portfolio.py +++ b/app/services/api/src/finquest_api/routers/portfolio.py @@ -95,6 +95,8 @@ async def add_position( portfolioId=str(portfolio.id), transactionIds=[str(tx_id) for tx_id in transaction_ids], ) + except HTTPException: + raise except ValueError as e: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, diff --git a/app/services/api/src/finquest_api/routers/users.py b/app/services/api/src/finquest_api/routers/users.py index 8f3f366..1d30c20 100644 --- a/app/services/api/src/finquest_api/routers/users.py +++ b/app/services/api/src/finquest_api/routers/users.py @@ -7,7 +7,7 @@ from ..auth_utils import get_current_user from ..db.models import User, OnboardingResponse, Suggestion from ..db.session import get_session, SessionLocal, get_engine -from ..schemas import UpdateProfileRequest, SuggestionResponse +from ..schemas import UpdateProfileRequest, SuggestionResponse, UserProfile from ..services.llm.service import LLMService from ..services.module_generator import ModuleGenerator from ..services.suggestion_generator import SuggestionGenerator @@ -36,6 +36,58 @@ async def generate_suggestions_task( finally: db.close() +@router.get("/onboarding-status") +async def get_onboarding_status( + user: User = Depends(get_current_user), + db: Session = Depends(get_session) +): + """ + Check if user has completed onboarding. + Returns True if user has an onboarding response, False otherwise. + """ + try: + onboarding_response = db.query(OnboardingResponse).filter( + OnboardingResponse.user_id == user.id + ).order_by(OnboardingResponse.submitted_at.desc()).first() + + return { + "completed": onboarding_response is not None + } + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to check onboarding status: {str(e)}" + ) + + +@router.get("/financial-profile", response_model=UserProfile) +async def get_financial_profile( + user: User = Depends(get_current_user), + db: Session = Depends(get_session) +): + """ + Get user's financial profile (onboarding data). + Returns the most recent onboarding response data. + """ + try: + onboarding_response = db.query(OnboardingResponse).filter( + OnboardingResponse.user_id == user.id + ).order_by(OnboardingResponse.submitted_at.desc()).first() + + if not onboarding_response or not onboarding_response.answers: + # Return empty profile if no onboarding data exists + return UserProfile() + + # Convert answers dict to UserProfile + return UserProfile(**onboarding_response.answers) + + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to get financial profile: {str(e)}" + ) + + @router.post("/financial-profile", status_code=status.HTTP_201_CREATED) async def update_financial_profile( request: UpdateProfileRequest, @@ -89,11 +141,12 @@ async def get_suggestions( If no suggestions exist, triggers generation in background and returns empty list. """ try: - # Fetch existing suggestions + # Fetch existing suggestions (including completed ones to show in pathway) + # Order by creation time to preserve the natural sequence in the pathway suggestions = db.query(Suggestion).filter( Suggestion.user_id == user.id, - Suggestion.status == "shown" - ).order_by(Suggestion.confidence.desc()).all() + Suggestion.status.in_(["shown", "completed"]) + ).order_by(Suggestion.created_at.asc()).all() # If no suggestions, generate them in background if not suggestions: diff --git a/app/services/api/src/finquest_api/schemas.py b/app/services/api/src/finquest_api/schemas.py index 8ee722a..1d72b74 100644 --- a/app/services/api/src/finquest_api/schemas.py +++ b/app/services/api/src/finquest_api/schemas.py @@ -96,6 +96,7 @@ class UserProfile(BaseModel): annualIncome: Optional[str] = None investmentAmount: Optional[str] = None riskTolerance: Optional[str] = None + country: Optional[str] = Field(None, description="ISO 3166-1 alpha-2 country code (e.g., 'US', 'CA', 'GB')") class UpdateProfileRequest(UserProfile): diff --git a/app/services/api/src/finquest_api/services/gamification.py b/app/services/api/src/finquest_api/services/gamification.py new file mode 100644 index 0000000..8c4b536 --- /dev/null +++ b/app/services/api/src/finquest_api/services/gamification.py @@ -0,0 +1,261 @@ +""" +Gamification service for XP, levels, streaks, and badges. +""" +from __future__ import annotations + +from datetime import date, timedelta +from uuid import UUID + +from sqlalchemy.orm import Session + +from ..db.models import ( + UserGamificationStats, + BadgeDefinition, + UserBadge, + ModuleCompletion, + Transaction, + Portfolio, +) + + +# XP Rewards (fixed values) +XP_REWARDS = { + "login": 10, + "module_completed": 25, + "module_completed_first_time": 50, + "quiz_completed_high": 35, # score >= 80% + "quiz_completed_low": 20, # score < 80% + "portfolio_position_added": 40, + "portfolio_position_updated": 20, + "streak_bonus": 2, # per streak day increment +} + +# Level thresholds +LEVEL_THRESHOLDS = [ + (1, 0), + (2, 200), + (3, 400), + (4, 600), + (5, 800), + (6, 1000), + (7, 1500), + (8, 2000), + (9, 2500), + (10, 3000), +] + + +def compute_level(total_xp: int) -> int: + """ + Calculate user level based on total XP. + Levels 1-5: 200 XP per level + Levels 6-10: 500 XP per level + Max level: 10 + """ + level = 1 + for lvl, threshold in LEVEL_THRESHOLDS: + if total_xp >= threshold: + level = lvl + else: + break + return min(level, 10) # Cap at level 10 + + +def get_xp_to_next_level(total_xp: int, level: int) -> int: + """Calculate XP needed to reach next level.""" + if level >= 10: + return 0 + + next_level_threshold = next( + (threshold for lvl, threshold in LEVEL_THRESHOLDS if lvl == level + 1), + None + ) + if next_level_threshold is None: + return 0 + + return next_level_threshold - total_xp + + +def get_or_create_stats(db: Session, user_id: UUID) -> UserGamificationStats: + """Get or create user gamification stats.""" + stats = db.query(UserGamificationStats).filter( + UserGamificationStats.user_id == user_id + ).first() + + if not stats: + stats = UserGamificationStats( + user_id=user_id, + total_xp=0, + level=1, + current_streak=0, + total_modules_completed=0, + total_quizzes_completed=0, + total_portfolio_positions=0, + ) + db.add(stats) + db.commit() + db.refresh(stats) + + return stats + + +def update_streak( + db: Session, + stats: UserGamificationStats, + quiz_date: date +) -> bool: + """ + Update streak based on quiz completion date. + Returns True if streak was incremented, False otherwise. + """ + if stats.last_streak_date is None: + # First streak + stats.current_streak = 1 + stats.last_streak_date = quiz_date + return True + + if stats.last_streak_date == quiz_date: + # Already counted today + return False + + if stats.last_streak_date == quiz_date - timedelta(days=1): + # Consecutive day + stats.current_streak += 1 + stats.last_streak_date = quiz_date + return True + else: + # Streak broken, reset to 1 + stats.current_streak = 1 + stats.last_streak_date = quiz_date + return True + + +def evaluate_badges( + db: Session, + user_id: UUID, + stats: UserGamificationStats +) -> list[dict]: + """ + Evaluate badge conditions and award new badges. + Returns list of newly awarded badge dictionaries. + """ + # Get existing badge codes for this user + existing_badges = db.query(BadgeDefinition.code).join(UserBadge).filter( + UserBadge.user_id == user_id + ).all() + existing_codes = {badge[0] for badge in existing_badges} + + new_badges = [] + + # Learning badges + if stats.total_modules_completed >= 5 and "MODULE_5" not in existing_codes: + badge = db.query(BadgeDefinition).filter(BadgeDefinition.code == "MODULE_5").first() + if badge and badge.is_active: + user_badge = UserBadge(user_id=user_id, badge_id=badge.id) + db.add(user_badge) + new_badges.append({ + "code": badge.code, + "name": badge.name, + "description": badge.description, + }) + + if stats.total_modules_completed >= 10 and "MODULE_10" not in existing_codes: + badge = db.query(BadgeDefinition).filter(BadgeDefinition.code == "MODULE_10").first() + if badge and badge.is_active: + user_badge = UserBadge(user_id=user_id, badge_id=badge.id) + db.add(user_badge) + new_badges.append({ + "code": badge.code, + "name": badge.name, + "description": badge.description, + }) + + if stats.total_modules_completed >= 20 and "MODULE_20" not in existing_codes: + badge = db.query(BadgeDefinition).filter(BadgeDefinition.code == "MODULE_20").first() + if badge and badge.is_active: + user_badge = UserBadge(user_id=user_id, badge_id=badge.id) + db.add(user_badge) + new_badges.append({ + "code": badge.code, + "name": badge.name, + "description": badge.description, + }) + + # Streak badges + if stats.current_streak >= 7 and "STREAK_7" not in existing_codes: + badge = db.query(BadgeDefinition).filter(BadgeDefinition.code == "STREAK_7").first() + if badge and badge.is_active: + user_badge = UserBadge(user_id=user_id, badge_id=badge.id) + db.add(user_badge) + new_badges.append({ + "code": badge.code, + "name": badge.name, + "description": badge.description, + }) + + if stats.current_streak >= 30 and "STREAK_30" not in existing_codes: + badge = db.query(BadgeDefinition).filter(BadgeDefinition.code == "STREAK_30").first() + if badge and badge.is_active: + user_badge = UserBadge(user_id=user_id, badge_id=badge.id) + db.add(user_badge) + new_badges.append({ + "code": badge.code, + "name": badge.name, + "description": badge.description, + }) + + # Portfolio badges + if stats.total_portfolio_positions >= 1 and "PORTFOLIO_CREATOR" not in existing_codes: + badge = db.query(BadgeDefinition).filter(BadgeDefinition.code == "PORTFOLIO_CREATOR").first() + if badge and badge.is_active: + user_badge = UserBadge(user_id=user_id, badge_id=badge.id) + db.add(user_badge) + new_badges.append({ + "code": badge.code, + "name": badge.name, + "description": badge.description, + }) + + if stats.total_portfolio_positions >= 3 and "DIVERSIFIER" not in existing_codes: + badge = db.query(BadgeDefinition).filter(BadgeDefinition.code == "DIVERSIFIER").first() + if badge and badge.is_active: + user_badge = UserBadge(user_id=user_id, badge_id=badge.id) + db.add(user_badge) + new_badges.append({ + "code": badge.code, + "name": badge.name, + "description": badge.description, + }) + + return new_badges + + +def check_module_first_time(db: Session, user_id: UUID, module_id: UUID) -> bool: + """Check if this is the first time user completed this module.""" + existing = db.query(ModuleCompletion).filter( + ModuleCompletion.user_id == user_id, + ModuleCompletion.module_id == module_id + ).first() + return existing is None + + +def get_portfolio_position_count(db: Session, user_id: UUID) -> int: + """Get count of distinct portfolio positions for user.""" + portfolio = db.query(Portfolio).filter( + Portfolio.user_id == user_id, + Portfolio.deleted_at.is_(None) + ).first() + + if not portfolio: + return 0 + + # Count distinct instruments in transactions + from sqlalchemy import distinct + + count = db.query(distinct(Transaction.instrument_id)).filter( + Transaction.portfolio_id == portfolio.id, + Transaction.deleted_at.is_(None) + ).count() + + return count + diff --git a/app/services/api/src/finquest_api/services/module_generator.py b/app/services/api/src/finquest_api/services/module_generator.py index d99b2fe..ffd64b5 100644 --- a/app/services/api/src/finquest_api/services/module_generator.py +++ b/app/services/api/src/finquest_api/services/module_generator.py @@ -43,8 +43,11 @@ async def generate_module_from_profile( profile_context += f"- Experience Level: {answers.get('investingExperience', 'Not specified')}\n" profile_context += f"- Risk Tolerance: {answers.get('riskTolerance', 'Not specified')}\n" profile_context += f"- Investment Horizon: {answers.get('investmentHorizon', 'Not specified')}\n" + country = answers.get('country', 'US') + profile_context += f"- Country: {country}\n" else: profile_context += "No specific profile data available.\n" + country = 'US' # Default to US if no profile data # Get portfolio context portfolio_context = "Portfolio Context:\n" @@ -88,18 +91,37 @@ async def generate_module_from_profile( "Output MUST be valid JSON matching the specified schema." ) + # Get country for context + country = onboarding.answers.get('country', 'US') if onboarding else 'US' + country_name = { + 'US': 'United States', 'CA': 'Canada', 'GB': 'United Kingdom', 'AU': 'Australia', + 'DE': 'Germany', 'FR': 'France', 'IT': 'Italy', 'ES': 'Spain', 'NL': 'Netherlands', + 'BE': 'Belgium', 'CH': 'Switzerland', 'AT': 'Austria', 'SE': 'Sweden', 'NO': 'Norway', + 'DK': 'Denmark', 'FI': 'Finland', 'IE': 'Ireland', 'PT': 'Portugal', 'PL': 'Poland', + 'CZ': 'Czech Republic', 'GR': 'Greece', 'JP': 'Japan', 'CN': 'China', 'IN': 'India', + 'SG': 'Singapore', 'HK': 'Hong Kong', 'KR': 'South Korea', 'TW': 'Taiwan', 'NZ': 'New Zealand', + 'BR': 'Brazil', 'MX': 'Mexico', 'AR': 'Argentina', 'ZA': 'South Africa', 'AE': 'United Arab Emirates', + 'IL': 'Israel', 'TR': 'Turkey', 'RU': 'Russia' + }.get(country, country) + user_prompt = ( f"Create a learning module about: '{topic}'.\n" f"Reason for recommendation: {reason}\n\n" f"{profile_context}\n" f"{portfolio_context}\n\n" - "Requirements:\n" - "1. Title: Catchy and relevant.\n" - "2. Body: Markdown format. Use headers, bullet points, and bold text. " - "Explain the concept using the user's specific context (e.g. 'Since your goal is X...'). " - "Keep it under 500 words.\n" - "3. Questions: Exactly 3 multiple-choice questions to test understanding. " - "Each question must have 3-4 choices, one correct." + f"IMPORTANT: The user is based in {country_name} ({country}). " + f"Tailor all content to their country's financial regulations, tax systems, investment options, " + f"and market practices. Use country-specific examples, regulations, and financial products where relevant. " + f"If discussing tax-advantaged accounts, use the appropriate accounts for their country (e.g., RRSP/TFSA for Canada, " + f"ISA for UK, Superannuation for Australia, 401(k)/IRA for US, etc.).\n\n" + f"Requirements:\n" + f"1. Title: Catchy and relevant.\n" + f"2. Body: Markdown format. Use headers, bullet points, and bold text. " + f"Explain the concept using the user's specific context (e.g. 'Since your goal is X...'). " + f"Tailor examples and explanations to {country_name} financial systems and regulations. " + f"Keep it under 500 words.\n" + f"3. Questions: Exactly 3 multiple-choice questions to test understanding. " + f"Each question must have 3-4 choices, one correct. Questions should be relevant to {country_name} financial context." ) # Call LLM diff --git a/app/services/api/src/finquest_api/services/suggestion_generator.py b/app/services/api/src/finquest_api/services/suggestion_generator.py index de9d6de..7c0a1f7 100644 --- a/app/services/api/src/finquest_api/services/suggestion_generator.py +++ b/app/services/api/src/finquest_api/services/suggestion_generator.py @@ -55,8 +55,11 @@ async def generate_suggestions_for_user( profile_context += f"- Experience Level: {answers.get('investingExperience', 'Not specified')}\n" profile_context += f"- Risk Tolerance: {answers.get('riskTolerance', 'Not specified')}\n" profile_context += f"- Investment Horizon: {answers.get('investmentHorizon', 'Not specified')}\n" + country = answers.get('country', 'US') + profile_context += f"- Country: {country}\n" else: profile_context += "No specific profile data available.\n" + country = 'US' # Default to US if no profile data portfolio_context = "Portfolio Context:\n" if user.portfolio: @@ -100,13 +103,29 @@ async def generate_suggestions_for_user( f"Avoid these topics: {', '.join(existing_topics) if existing_topics else 'None'}" ) + # Get country for context + country = onboarding.answers.get('country', 'US') if onboarding else 'US' + country_name = { + 'US': 'United States', 'CA': 'Canada', 'GB': 'United Kingdom', 'AU': 'Australia', + 'DE': 'Germany', 'FR': 'France', 'IT': 'Italy', 'ES': 'Spain', 'NL': 'Netherlands', + 'BE': 'Belgium', 'CH': 'Switzerland', 'AT': 'Austria', 'SE': 'Sweden', 'NO': 'Norway', + 'DK': 'Denmark', 'FI': 'Finland', 'IE': 'Ireland', 'PT': 'Portugal', 'PL': 'Poland', + 'CZ': 'Czech Republic', 'GR': 'Greece', 'JP': 'Japan', 'CN': 'China', 'IN': 'India', + 'SG': 'Singapore', 'HK': 'Hong Kong', 'KR': 'South Korea', 'TW': 'Taiwan', 'NZ': 'New Zealand', + 'BR': 'Brazil', 'MX': 'Mexico', 'AR': 'Argentina', 'ZA': 'South Africa', 'AE': 'United Arab Emirates', + 'IL': 'Israel', 'TR': 'Turkey', 'RU': 'Russia' + }.get(country, country) + user_prompt = ( f"Analyze this user:\n\n" f"{profile_context}\n" f"{portfolio_context}\n\n" + f"IMPORTANT: The user is based in {country_name} ({country}). " + "Tailor all suggestions to their country's financial regulations, tax systems, investment options, " + "and market practices. Use country-specific examples, regulations, and financial products where relevant.\n\n" "Generate suggestions. If the user is a beginner or has no portfolio, focus on foundational concepts " - "relevant to their goals. If they have a portfolio, look for concentration risk, diversification issues, " - "or alignment with their risk tolerance." + "relevant to their goals and country. If they have a portfolio, look for concentration risk, diversification issues, " + "or alignment with their risk tolerance, considering their country's market context." ) # Call LLM diff --git a/app/services/api/tests/services/test_fx.py b/app/services/api/tests/services/test_fx.py new file mode 100644 index 0000000..86fd892 --- /dev/null +++ b/app/services/api/tests/services/test_fx.py @@ -0,0 +1,118 @@ +""" +Tests for FX service +""" +from unittest.mock import Mock, MagicMock, patch +from decimal import Decimal +from datetime import datetime, timedelta, timezone + +from finquest_api.services.fx import fx_now +from finquest_api.db.models import FxRateSnapshot + + +class TestFxNow: + """Tests for fx_now function""" + + def test_same_currency(self): + """Test FX rate for same currency""" + mock_db = MagicMock() + + result = fx_now(mock_db, "USD", "USD") + + assert result == Decimal("1.0") + mock_db.query.assert_not_called() + + def test_recent_rate_from_db(self): + """Test using recent rate from database""" + mock_db = MagicMock() + mock_snapshot = Mock(spec=FxRateSnapshot) + mock_snapshot.rate = Decimal("1.25") + # Create datetime with timezone + mock_snapshot.as_of = datetime.now(timezone.utc) - timedelta(hours=1) + + mock_query = Mock() + mock_query.filter.return_value.order_by.return_value.first.return_value = mock_snapshot + mock_db.query.return_value = mock_query + + result = fx_now(mock_db, "EUR", "USD") + + assert result == Decimal("1.25") + + def test_recent_rate_timezone_naive(self): + """Test using recent rate with timezone-naive datetime (line 38-40)""" + mock_db = MagicMock() + mock_snapshot = Mock(spec=FxRateSnapshot) + mock_snapshot.rate = Decimal("1.25") + # Create timezone-naive datetime that's recent (within 24 hours) + # Use a fixed time that's definitely within 24 hours of "now" + now_utc = datetime.now(timezone.utc) + naive_time = (now_utc - timedelta(hours=1)).replace(tzinfo=None) # Remove timezone to make it naive + mock_snapshot.as_of = naive_time + + mock_query = Mock() + mock_query.filter.return_value.order_by.return_value.first.return_value = mock_snapshot + mock_db.query.return_value = mock_query + + # Mock the provider to not be called (since we want to test the DB path) + mock_provider = Mock() + mock_provider.get_fx_rate.return_value = None + + with patch('finquest_api.services.fx.get_provider', return_value=mock_provider): + result = fx_now(mock_db, "EUR", "USD") + + assert result == Decimal("1.25") + # Provider should not be called since DB rate is recent + mock_provider.get_fx_rate.assert_not_called() + + def test_stale_rate_from_db(self): + """Test when DB rate is stale""" + mock_db = MagicMock() + mock_snapshot = Mock(spec=FxRateSnapshot) + mock_snapshot.rate = Decimal("1.25") + mock_snapshot.as_of = datetime.now(timezone.utc) - timedelta(hours=25) + + mock_query = Mock() + mock_query.filter.return_value.order_by.return_value.first.return_value = mock_snapshot + mock_db.query.return_value = mock_query + + mock_provider = Mock() + mock_provider.get_fx_rate.return_value = Decimal("1.30") + + with patch('finquest_api.services.fx.get_provider', return_value=mock_provider): + result = fx_now(mock_db, "EUR", "USD") + + assert result == Decimal("1.30") + mock_provider.get_fx_rate.assert_called_once() + + def test_no_rate_in_db(self): + """Test when no rate exists in database""" + mock_db = MagicMock() + mock_query = Mock() + mock_query.filter.return_value.order_by.return_value.first.return_value = None + mock_db.query.return_value = mock_query + + mock_provider = Mock() + mock_provider.get_fx_rate.return_value = Decimal("1.30") + + with patch('finquest_api.services.fx.get_provider', return_value=mock_provider): + result = fx_now(mock_db, "EUR", "USD") + + assert result == Decimal("1.30") + mock_db.add.assert_called_once() + mock_db.commit.assert_called_once() + + def test_provider_returns_none(self): + """Test when provider returns None""" + mock_db = MagicMock() + mock_query = Mock() + mock_query.filter.return_value.order_by.return_value.first.return_value = None + mock_db.query.return_value = mock_query + + mock_provider = Mock() + mock_provider.get_fx_rate.return_value = None + + with patch('finquest_api.services.fx.get_provider', return_value=mock_provider): + result = fx_now(mock_db, "EUR", "USD") + + assert result is None + mock_db.add.assert_not_called() + diff --git a/app/services/api/tests/services/test_fx_extended.py b/app/services/api/tests/services/test_fx_extended.py new file mode 100644 index 0000000..34a7e62 --- /dev/null +++ b/app/services/api/tests/services/test_fx_extended.py @@ -0,0 +1,125 @@ +""" +Extended tests for FX service +""" +from unittest.mock import Mock, MagicMock, patch +from decimal import Decimal +from datetime import datetime, timedelta, timezone + +from finquest_api.services.fx import fx_at, convert_to_base +from finquest_api.db.models import FxRateSnapshot + + +class TestFxAt: + """Tests for fx_at function""" + + def test_same_currency(self): + """Test FX rate for same currency""" + mock_db = MagicMock() + when = datetime.now(timezone.utc) + + result = fx_at(mock_db, "USD", "USD", when) + + assert result == Decimal("1.0") + mock_db.query.assert_not_called() + + def test_recent_rate_from_db(self): + """Test using recent rate from database""" + mock_db = MagicMock() + when = datetime.now(timezone.utc) + mock_snapshot = Mock(spec=FxRateSnapshot) + mock_snapshot.rate = Decimal("1.25") + mock_snapshot.as_of = when - timedelta(hours=1) + + mock_query = Mock() + mock_query.filter.return_value.order_by.return_value.first.return_value = mock_snapshot + mock_db.query.return_value = mock_query + + result = fx_at(mock_db, "EUR", "USD", when) + + assert result == Decimal("1.25") + + def test_stale_rate_from_db(self): + """Test when DB rate is stale""" + mock_db = MagicMock() + when = datetime.now(timezone.utc) + mock_snapshot = Mock(spec=FxRateSnapshot) + mock_snapshot.rate = Decimal("1.25") + mock_snapshot.as_of = when - timedelta(hours=25) + + mock_query = Mock() + mock_query.filter.return_value.order_by.return_value.first.return_value = mock_snapshot + mock_db.query.return_value = mock_query + + mock_provider = Mock() + mock_provider.get_fx_rate.return_value = Decimal("1.30") + + with patch('finquest_api.services.fx.get_provider', return_value=mock_provider): + result = fx_at(mock_db, "EUR", "USD", when) + + assert result == Decimal("1.30") + mock_provider.get_fx_rate.assert_called_once() + + def test_no_rate_in_db(self): + """Test when no rate exists in database""" + mock_db = MagicMock() + when = datetime.now(timezone.utc) + mock_query = Mock() + mock_query.filter.return_value.order_by.return_value.first.return_value = None + mock_db.query.return_value = mock_query + + mock_provider = Mock() + mock_provider.get_fx_rate.return_value = Decimal("1.30") + + with patch('finquest_api.services.fx.get_provider', return_value=mock_provider): + result = fx_at(mock_db, "EUR", "USD", when) + + assert result == Decimal("1.30") + mock_db.add.assert_called_once() + mock_db.commit.assert_called_once() + + +class TestConvertToBase: + """Tests for convert_to_base function""" + + def test_same_currency(self): + """Test conversion with same currency""" + mock_db = MagicMock() + amount = Decimal("100.0") + + result = convert_to_base(mock_db, amount, "USD", "USD") + + assert result == amount + mock_db.query.assert_not_called() + + def test_convert_with_fx_now(self): + """Test conversion using fx_now""" + mock_db = MagicMock() + amount = Decimal("100.0") + + with patch('finquest_api.services.fx.fx_now', return_value=Decimal("1.25")): + result = convert_to_base(mock_db, amount, "EUR", "USD", None) + + assert result == Decimal("125.0") + + def test_convert_with_fx_at(self): + """Test conversion using fx_at""" + mock_db = MagicMock() + amount = Decimal("100.0") + when = datetime.now(timezone.utc) + + with patch('finquest_api.services.fx.fx_at', return_value=Decimal("1.25")): + result = convert_to_base(mock_db, amount, "EUR", "USD", when) + + assert result == Decimal("125.0") + + def test_convert_no_rate_available(self): + """Test conversion when rate is not available""" + mock_db = MagicMock() + amount = Decimal("100.0") + + with patch('finquest_api.services.fx.fx_now', return_value=None): + result = convert_to_base(mock_db, amount, "EUR", "USD", None) + + assert result is None + + diff --git a/app/services/api/tests/services/test_gamification_service.py b/app/services/api/tests/services/test_gamification_service.py new file mode 100644 index 0000000..07c0959 --- /dev/null +++ b/app/services/api/tests/services/test_gamification_service.py @@ -0,0 +1,340 @@ +""" +Tests for gamification service functions +""" +from unittest.mock import Mock, MagicMock, patch +from uuid import uuid4 +from datetime import date, timedelta + +from finquest_api.services.gamification import ( + compute_level, + get_xp_to_next_level, + get_or_create_stats, + update_streak, + evaluate_badges, + check_module_first_time, + get_portfolio_position_count, +) +from finquest_api.db.models import ( + UserGamificationStats, + BadgeDefinition, + ModuleCompletion, + Portfolio, +) + + +class TestComputeLevel: + """Tests for compute_level function""" + + def test_compute_level_1(self): + """Test level 1 (0 XP)""" + assert compute_level(0) == 1 + assert compute_level(100) == 1 + + def test_compute_level_2(self): + """Test level 2 (200 XP)""" + assert compute_level(200) == 2 + assert compute_level(300) == 2 + + def test_compute_level_5(self): + """Test level 5 (800 XP)""" + assert compute_level(800) == 5 + assert compute_level(900) == 5 + + def test_compute_level_10(self): + """Test level 10 (3000+ XP)""" + assert compute_level(3000) == 10 + assert compute_level(5000) == 10 # Capped at 10 + + +class TestGetXpToNextLevel: + """Tests for get_xp_to_next_level function""" + + def test_xp_to_next_level_1(self): + """Test XP needed from level 1 to 2""" + assert get_xp_to_next_level(0, 1) == 200 + assert get_xp_to_next_level(100, 1) == 100 + + def test_xp_to_next_level_5(self): + """Test XP needed from level 5 to 6""" + assert get_xp_to_next_level(800, 5) == 200 + assert get_xp_to_next_level(900, 5) == 100 + + def test_xp_to_next_level_10(self): + """Test XP needed at max level""" + assert get_xp_to_next_level(3000, 10) == 0 + assert get_xp_to_next_level(5000, 10) == 0 + + +class TestGetOrCreateStats: + """Tests for get_or_create_stats function""" + + def test_get_existing_stats(self): + """Test getting existing stats""" + mock_db = MagicMock() + mock_stats = Mock(spec=UserGamificationStats) + mock_db.query.return_value.filter.return_value.first.return_value = mock_stats + + user_id = uuid4() + result = get_or_create_stats(mock_db, user_id) + + assert result == mock_stats + mock_db.add.assert_not_called() + + def test_create_new_stats(self): + """Test creating new stats""" + mock_db = MagicMock() + mock_db.query.return_value.filter.return_value.first.return_value = None + + new_stats = Mock(spec=UserGamificationStats) + with patch('finquest_api.services.gamification.UserGamificationStats', return_value=new_stats): + user_id = uuid4() + get_or_create_stats(mock_db, user_id) + + mock_db.add.assert_called_once() + mock_db.commit.assert_called_once() + mock_db.refresh.assert_called_once() + + +class TestUpdateStreak: + """Tests for update_streak function""" + + def test_first_streak(self): + """Test first streak""" + mock_db = MagicMock() + mock_stats = Mock(spec=UserGamificationStats) + mock_stats.last_streak_date = None + mock_stats.current_streak = 0 + + quiz_date = date.today() + result = update_streak(mock_db, mock_stats, quiz_date) + + assert result is True + assert mock_stats.current_streak == 1 + assert mock_stats.last_streak_date == quiz_date + + def test_consecutive_day(self): + """Test consecutive day streak""" + mock_db = MagicMock() + mock_stats = Mock(spec=UserGamificationStats) + mock_stats.last_streak_date = date.today() - timedelta(days=1) + mock_stats.current_streak = 5 + + quiz_date = date.today() + result = update_streak(mock_db, mock_stats, quiz_date) + + assert result is True + assert mock_stats.current_streak == 6 + assert mock_stats.last_streak_date == quiz_date + + def test_same_day(self): + """Test same day (no increment)""" + mock_db = MagicMock() + mock_stats = Mock(spec=UserGamificationStats) + today = date.today() + mock_stats.last_streak_date = today + mock_stats.current_streak = 5 + + result = update_streak(mock_db, mock_stats, today) + + assert result is False + assert mock_stats.current_streak == 5 + + def test_streak_broken(self): + """Test streak broken (reset to 1)""" + mock_db = MagicMock() + mock_stats = Mock(spec=UserGamificationStats) + mock_stats.last_streak_date = date.today() - timedelta(days=3) + mock_stats.current_streak = 10 + + quiz_date = date.today() + result = update_streak(mock_db, mock_stats, quiz_date) + + assert result is True + assert mock_stats.current_streak == 1 + assert mock_stats.last_streak_date == quiz_date + + +class TestEvaluateBadges: + """Tests for evaluate_badges function""" + + def test_module_5_badge(self): + """Test MODULE_5 badge evaluation""" + mock_db = MagicMock() + user_id = uuid4() + + mock_stats = Mock(spec=UserGamificationStats) + mock_stats.total_modules_completed = 5 + mock_stats.current_streak = 0 + mock_stats.total_portfolio_positions = 0 + + mock_badge = Mock(spec=BadgeDefinition) + mock_badge.id = uuid4() + mock_badge.code = "MODULE_5" + mock_badge.name = "5 Modules" + mock_badge.description = "Complete 5 modules" + mock_badge.is_active = True + + # Mock existing badges query (empty) + mock_existing_query = Mock() + mock_existing_query.join.return_value.filter.return_value.all.return_value = [] + + # Mock badge definition query + mock_badge_query = Mock() + mock_badge_query.filter.return_value.first.return_value = mock_badge + + mock_db.query.side_effect = [mock_existing_query, mock_badge_query] + + result = evaluate_badges(mock_db, user_id, mock_stats) + + assert len(result) == 1 + assert result[0]["code"] == "MODULE_5" + mock_db.add.assert_called_once() + + def test_streak_7_badge(self): + """Test STREAK_7 badge evaluation""" + mock_db = MagicMock() + user_id = uuid4() + + mock_stats = Mock(spec=UserGamificationStats) + mock_stats.total_modules_completed = 0 + mock_stats.current_streak = 7 + mock_stats.total_portfolio_positions = 0 + + mock_badge = Mock(spec=BadgeDefinition) + mock_badge.id = uuid4() + mock_badge.code = "STREAK_7" + mock_badge.name = "7 Day Streak" + mock_badge.description = "7 day streak" + mock_badge.is_active = True + + # Mock existing badges query (empty) + mock_existing_query = Mock() + mock_existing_query.join.return_value.filter.return_value.all.return_value = [] + + # Mock badge definition query + mock_badge_query = Mock() + mock_badge_query.filter.return_value.first.return_value = mock_badge + + mock_db.query.side_effect = [mock_existing_query, mock_badge_query] + + result = evaluate_badges(mock_db, user_id, mock_stats) + + assert len(result) == 1 + assert result[0]["code"] == "STREAK_7" + + def test_portfolio_creator_badge(self): + """Test PORTFOLIO_CREATOR badge evaluation""" + mock_db = MagicMock() + user_id = uuid4() + + mock_stats = Mock(spec=UserGamificationStats) + mock_stats.total_modules_completed = 0 + mock_stats.current_streak = 0 + mock_stats.total_portfolio_positions = 1 + + mock_badge = Mock(spec=BadgeDefinition) + mock_badge.id = uuid4() + mock_badge.code = "PORTFOLIO_CREATOR" + mock_badge.name = "Portfolio Creator" + mock_badge.description = "Add first position" + mock_badge.is_active = True + + # Mock existing badges query (empty) + mock_existing_query = Mock() + mock_existing_query.join.return_value.filter.return_value.all.return_value = [] + + # Mock badge definition query + mock_badge_query = Mock() + mock_badge_query.filter.return_value.first.return_value = mock_badge + + mock_db.query.side_effect = [mock_existing_query, mock_badge_query] + + result = evaluate_badges(mock_db, user_id, mock_stats) + + assert len(result) == 1 + assert result[0]["code"] == "PORTFOLIO_CREATOR" + + def test_no_new_badges(self): + """Test when no new badges are earned""" + mock_db = MagicMock() + user_id = uuid4() + + mock_stats = Mock(spec=UserGamificationStats) + mock_stats.total_modules_completed = 0 + mock_stats.current_streak = 0 + mock_stats.total_portfolio_positions = 0 + + # Mock existing badges query (empty) + mock_existing_query = Mock() + mock_existing_query.join.return_value.filter.return_value.all.return_value = [] + + mock_db.query.return_value = mock_existing_query + + result = evaluate_badges(mock_db, user_id, mock_stats) + + assert len(result) == 0 + + +class TestCheckModuleFirstTime: + """Tests for check_module_first_time function""" + + def test_first_time_true(self): + """Test first time completion""" + mock_db = MagicMock() + mock_db.query.return_value.filter.return_value.first.return_value = None + + user_id = uuid4() + module_id = uuid4() + + result = check_module_first_time(mock_db, user_id, module_id) + + assert result is True + + def test_first_time_false(self): + """Test not first time completion""" + mock_db = MagicMock() + mock_completion = Mock(spec=ModuleCompletion) + mock_db.query.return_value.filter.return_value.first.return_value = mock_completion + + user_id = uuid4() + module_id = uuid4() + + result = check_module_first_time(mock_db, user_id, module_id) + + assert result is False + + +class TestGetPortfolioPositionCount: + """Tests for get_portfolio_position_count function""" + + def test_no_portfolio(self): + """Test when user has no portfolio""" + mock_db = MagicMock() + mock_db.query.return_value.filter.return_value.first.return_value = None + + user_id = uuid4() + result = get_portfolio_position_count(mock_db, user_id) + + assert result == 0 + + def test_with_positions(self): + """Test counting positions""" + mock_db = MagicMock() + mock_portfolio = Mock(spec=Portfolio) + mock_portfolio.id = uuid4() + + # Mock portfolio query + mock_portfolio_query = Mock() + mock_portfolio_query.filter.return_value.first.return_value = mock_portfolio + + # Mock transaction query + mock_transaction_query = Mock() + mock_transaction_query.filter.return_value.count.return_value = 3 + + mock_db.query.side_effect = [mock_portfolio_query, mock_transaction_query] + + user_id = uuid4() + result = get_portfolio_position_count(mock_db, user_id) + + assert result == 3 + diff --git a/app/services/api/tests/services/test_gemini_provider_extended.py b/app/services/api/tests/services/test_gemini_provider_extended.py new file mode 100644 index 0000000..946fca2 --- /dev/null +++ b/app/services/api/tests/services/test_gemini_provider_extended.py @@ -0,0 +1,168 @@ +""" +Extended tests for Gemini provider to cover missing lines +""" +import pytest +from pydantic import SecretStr + +from finquest_api.services.llm.providers.gemini import GeminiChatClient +from finquest_api.services.llm.client_base import ProviderRequestError +from finquest_api.services.llm.models import LLMCompletionRequest, LLMMessage, StructuredOutputConfig +from finquest_api.config import LLMSettings + + +class DummyResponse: + """Lightweight httpx.Response stand-in.""" + + def __init__(self, status_code=200, json_data=None, text=""): + self.status_code = status_code + self._json = json_data or {} + self.text = text + + def json(self): + return self._json + + +class DummyAsyncClient: + """Captures outgoing HTTP requests made by the provider.""" + + response: DummyResponse = DummyResponse() + last_request = None + + def __init__(self, *args, **kwargs): + self._entered = False + + async def __aenter__(self): + self._entered = True + return self + + async def __aexit__(self, exc_type, exc, tb): + self._entered = False + return False + + async def post(self, url, params=None, json=None, headers=None): + DummyAsyncClient.last_request = { + "url": url, + "params": params, + "json": json, + "headers": headers, + } + return DummyAsyncClient.response + + +@pytest.mark.anyio("asyncio") +async def test_gemini_client_no_candidates(monkeypatch): + """Test Gemini client when no candidates returned (line 106)""" + response_body = { + "candidates": [], + "usageMetadata": { + "promptTokenCount": 1, + "candidatesTokenCount": 0, + "totalTokenCount": 1, + }, + } + DummyAsyncClient.response = DummyResponse(json_data=response_body) + monkeypatch.setattr( + "finquest_api.services.llm.providers.gemini.httpx.AsyncClient", + DummyAsyncClient, + ) + + settings = LLMSettings( + provider="gemini", + model="gemini-2.0-flash", + api_key=SecretStr("test-key"), + ) + client = GeminiChatClient(settings) + request = LLMCompletionRequest(messages=[LLMMessage(role="user", content="Hi")]) + + with pytest.raises(ProviderRequestError) as exc_info: + await client.acomplete(request) + + assert "no completion candidates" in str(exc_info.value).lower() + + +@pytest.mark.anyio("asyncio") +async def test_gemini_client_invalid_json_structured(monkeypatch): + """Test Gemini client with invalid JSON in structured output (lines 122-123)""" + response_body = { + "candidates": [ + { + "content": { + "role": "model", + "parts": [{"text": "Not valid JSON"}], + }, + "finishReason": "STOP", + } + ], + "usageMetadata": { + "promptTokenCount": 1, + "candidatesTokenCount": 1, + "totalTokenCount": 2, + }, + "responseId": "resp-invalid", + } + DummyAsyncClient.response = DummyResponse(json_data=response_body) + monkeypatch.setattr( + "finquest_api.services.llm.providers.gemini.httpx.AsyncClient", + DummyAsyncClient, + ) + + settings = LLMSettings( + provider="gemini", + model="gemini-2.0-flash", + api_key=SecretStr("test-key"), + ) + client = GeminiChatClient(settings) + structured = StructuredOutputConfig( + type="json_schema", + json_schema={"type": "object", "properties": {"answer": {"type": "string"}}}, + ) + request = LLMCompletionRequest( + messages=[LLMMessage(role="user", content="Answer")], + structured_output=structured, + ) + + result = await client.acomplete(request) + + # Should handle JSON decode error gracefully + assert result.structured_output is None + + +@pytest.mark.anyio("asyncio") +async def test_gemini_client_empty_content(monkeypatch): + """Test Gemini client with empty content (line 45)""" + response_body = { + "candidates": [ + { + "content": { + "role": "model", + "parts": [], + }, + "finishReason": "STOP", + } + ], + "usageMetadata": { + "promptTokenCount": 1, + "candidatesTokenCount": 0, + "totalTokenCount": 1, + }, + "responseId": "resp-empty", + } + DummyAsyncClient.response = DummyResponse(json_data=response_body) + monkeypatch.setattr( + "finquest_api.services.llm.providers.gemini.httpx.AsyncClient", + DummyAsyncClient, + ) + + settings = LLMSettings( + provider="gemini", + model="gemini-2.0-flash", + api_key=SecretStr("test-key"), + ) + client = GeminiChatClient(settings) + request = LLMCompletionRequest(messages=[LLMMessage(role="user", content="Hi")]) + + result = await client.acomplete(request) + + assert result.message.content == "" + + diff --git a/app/services/api/tests/services/test_instruments.py b/app/services/api/tests/services/test_instruments.py new file mode 100644 index 0000000..35e2ea9 --- /dev/null +++ b/app/services/api/tests/services/test_instruments.py @@ -0,0 +1,342 @@ +""" +Tests for instruments service +""" +import pytest +from unittest.mock import Mock, MagicMock, patch +from uuid import uuid4 +from decimal import Decimal +from datetime import datetime + +from finquest_api.services.instruments import ( + YFinanceProvider, + get_provider, + ensure_instrument, + ResolvedInstrument, +) +from finquest_api.db.models import Instrument + + +class TestYFinanceProvider: + """Tests for YFinanceProvider class""" + + def test_determine_type_crypto(self): + """Test determining instrument type as crypto""" + provider = YFinanceProvider() + info = {"quoteType": "CRYPTOCURRENCY"} + + result = provider._determine_type(info) + + assert result == "crypto" + + def test_determine_type_etf(self): + """Test determining instrument type as ETF""" + provider = YFinanceProvider() + info = {"quoteType": "ETF"} + + result = provider._determine_type(info) + + assert result == "etf" + + def test_determine_type_mutualfund(self): + """Test determining instrument type as mutual fund""" + provider = YFinanceProvider() + info = {"quoteType": "MUTUALFUND"} + + result = provider._determine_type(info) + + assert result == "etf" + + def test_determine_type_equity(self): + """Test determining instrument type as equity""" + provider = YFinanceProvider() + info = {"quoteType": "EQUITY"} + + result = provider._determine_type(info) + + assert result == "equity" + + def test_determine_type_default(self): + """Test determining instrument type default""" + provider = YFinanceProvider() + info = {"quoteType": "UNKNOWN"} + + result = provider._determine_type(info) + + assert result == "equity" + + def test_get_exchange_mic_nasdaq(self): + """Test getting MIC code for NASDAQ""" + provider = YFinanceProvider() + + result = provider._get_exchange_mic("NASDAQ") + + assert result == "XNAS" + + def test_get_exchange_mic_nyse(self): + """Test getting MIC code for NYSE""" + provider = YFinanceProvider() + + result = provider._get_exchange_mic("NYSE") + + assert result == "XNYS" + + def test_get_exchange_mic_unknown(self): + """Test getting MIC code for unknown exchange""" + provider = YFinanceProvider() + + result = provider._get_exchange_mic("UNKNOWN") + + assert result == "UNKNOWN" + + def test_get_exchange_mic_none(self): + """Test getting MIC code when exchange is None""" + provider = YFinanceProvider() + + result = provider._get_exchange_mic(None) + + assert result is None + + def test_get_country_code_exact_match(self): + """Test getting country code with exact match""" + provider = YFinanceProvider() + + result = provider._get_country_code("United States") + + assert result == "US" + + def test_get_country_code_case_insensitive(self): + """Test getting country code with case-insensitive match""" + provider = YFinanceProvider() + + result = provider._get_country_code("united states") + + assert result == "US" + + def test_get_country_code_already_code(self): + """Test getting country code when already a 2-character code""" + provider = YFinanceProvider() + + result = provider._get_country_code("US") + + assert result == "US" + + def test_get_country_code_unknown(self): + """Test getting country code for unknown country""" + provider = YFinanceProvider() + + result = provider._get_country_code("Unknown Country") + + assert result is None + + def test_get_country_code_none(self): + """Test getting country code when country is None""" + provider = YFinanceProvider() + + result = provider._get_country_code(None) + + assert result is None + + def test_resolve_symbol_success(self): + """Test successful symbol resolution""" + provider = YFinanceProvider() + mock_info = { + "symbol": "AAPL", + "longName": "Apple Inc.", + "exchange": "NASDAQ", + "currency": "USD", + "sector": "Technology", + "industry": "Consumer Electronics", + "country": "United States", + "quoteType": "EQUITY" + } + + mock_ticker = Mock() + mock_ticker.info = mock_info + + with patch('finquest_api.services.instruments.yf.Ticker', return_value=mock_ticker): + result = provider.resolve_symbol("AAPL") + + assert result.type == "equity" + assert result.symbol == "AAPL" + assert result.name == "Apple Inc." + assert result.exchange_mic == "XNAS" + assert result.currency == "USD" + assert result.sector == "Technology" + assert result.country == "US" + + def test_resolve_symbol_not_found(self): + """Test symbol resolution when symbol not found""" + provider = YFinanceProvider() + mock_ticker = Mock() + mock_ticker.info = {} + + with patch('finquest_api.services.instruments.yf.Ticker', return_value=mock_ticker): + with pytest.raises(ValueError) as exc_info: + provider.resolve_symbol("INVALID") + + assert "not found" in str(exc_info.value).lower() + + def test_resolve_symbol_exception(self): + """Test symbol resolution with exception""" + provider = YFinanceProvider() + + with patch('finquest_api.services.instruments.yf.Ticker', side_effect=Exception("Network error")): + with pytest.raises(ValueError) as exc_info: + provider.resolve_symbol("AAPL") + + assert "Failed to resolve symbol" in str(exc_info.value) + + def test_get_latest_price_not_implemented(self): + """Test get_latest_price raises NotImplementedError""" + provider = YFinanceProvider() + + with pytest.raises(NotImplementedError): + provider.get_latest_price(uuid4()) + + def test_backfill_eod_not_implemented(self): + """Test backfill_eod raises NotImplementedError""" + provider = YFinanceProvider() + + with pytest.raises(NotImplementedError): + provider.backfill_eod(uuid4(), datetime.now().date(), datetime.now().date()) + + def test_get_fx_rate_same_currency(self): + """Test FX rate for same currency""" + provider = YFinanceProvider() + + result = provider.get_fx_rate("USD", "USD", datetime.now()) + + assert result == Decimal("1.0") + + def test_get_fx_rate_success(self): + """Test successful FX rate retrieval""" + provider = YFinanceProvider() + mock_close_series = Mock() + mock_iloc = Mock() + mock_iloc.__getitem__ = Mock(return_value=110.5) + mock_close_series.iloc = mock_iloc + + mock_hist = Mock() + mock_hist.empty = False + mock_hist.__getitem__ = Mock(return_value=mock_close_series) + + mock_ticker = Mock() + mock_ticker.history.return_value = mock_hist + + with patch('finquest_api.services.instruments.yf.Ticker', return_value=mock_ticker): + result = provider.get_fx_rate("USD", "JPY", datetime.now()) + + assert result == Decimal("110.5") + + def test_get_fx_rate_empty_history(self): + """Test FX rate when history is empty""" + provider = YFinanceProvider() + mock_hist = Mock() + mock_hist.empty = True + + mock_ticker = Mock() + mock_ticker.history.return_value = mock_hist + + with patch('finquest_api.services.instruments.yf.Ticker', return_value=mock_ticker): + result = provider.get_fx_rate("USD", "JPY", datetime.now()) + + assert result is None + + def test_get_fx_rate_exception(self): + """Test FX rate with exception""" + provider = YFinanceProvider() + + with patch('finquest_api.services.instruments.yf.Ticker', side_effect=Exception("Error")): + result = provider.get_fx_rate("USD", "JPY", datetime.now()) + + assert result is None + + +class TestGetProvider: + """Tests for get_provider function""" + + def test_get_provider_singleton(self): + """Test that get_provider returns singleton instance""" + # Reset the global provider + import finquest_api.services.instruments as instruments_module + instruments_module._provider = None + + provider1 = get_provider() + provider2 = get_provider() + + assert provider1 is provider2 + assert isinstance(provider1, YFinanceProvider) + + +class TestEnsureInstrument: + """Tests for ensure_instrument function""" + + def test_ensure_instrument_existing(self): + """Test ensuring instrument that already exists""" + mock_db = MagicMock() + mock_instrument = Mock(spec=Instrument) + mock_instrument.symbol = "AAPL" + mock_instrument.exchange_mic = "XNAS" + mock_instrument.sector = None + mock_instrument.industry = None + mock_instrument.country = None + mock_instrument.name = None + + mock_resolved = ResolvedInstrument( + type="equity", + symbol="AAPL", + name="Apple Inc.", + exchange_mic="XNAS", + currency="USD", + sector="Technology", + industry="Consumer Electronics", + country="US" + ) + + mock_query = Mock() + mock_query.filter.return_value.first.return_value = mock_instrument + mock_db.query.return_value = mock_query + + with patch('finquest_api.services.instruments.get_provider') as mock_get_provider: + mock_provider = Mock() + mock_provider.resolve_symbol.return_value = mock_resolved + mock_get_provider.return_value = mock_provider + + result = ensure_instrument(mock_db, "AAPL") + + assert result == mock_instrument + mock_db.commit.assert_called_once() + + def test_ensure_instrument_new(self): + """Test ensuring new instrument""" + mock_db = MagicMock() + mock_resolved = ResolvedInstrument( + type="equity", + symbol="AAPL", + name="Apple Inc.", + exchange_mic="XNAS", + currency="USD", + sector="Technology", + industry="Consumer Electronics", + country="US" + ) + + mock_query = Mock() + mock_query.filter.return_value.first.return_value = None + mock_db.query.return_value = mock_query + + new_instrument = Mock(spec=Instrument) + new_instrument.id = uuid4() + + with patch('finquest_api.services.instruments.get_provider') as mock_get_provider: + mock_provider = Mock() + mock_provider.resolve_symbol.return_value = mock_resolved + mock_get_provider.return_value = mock_provider + + with patch('finquest_api.services.instruments.Instrument', return_value=new_instrument): + result = ensure_instrument(mock_db, "AAPL") + + assert result == new_instrument + mock_db.add.assert_called_once() + mock_db.commit.assert_called_once() + diff --git a/app/services/api/tests/services/test_llm_dependencies.py b/app/services/api/tests/services/test_llm_dependencies.py new file mode 100644 index 0000000..182c3de --- /dev/null +++ b/app/services/api/tests/services/test_llm_dependencies.py @@ -0,0 +1,46 @@ +""" +Tests for LLM dependencies +""" +import pytest +from finquest_api.services.llm.dependencies import get_llm_service, _singleton_llm_service +from finquest_api.services.llm.service import LLMService + + +class TestGetLLMService: + """Tests for get_llm_service dependency""" + + @pytest.mark.anyio("asyncio") + async def test_get_llm_service(self): + """Test getting LLM service""" + service = await get_llm_service() + + assert isinstance(service, LLMService) + + @pytest.mark.anyio("asyncio") + async def test_get_llm_service_singleton(self): + """Test that service is singleton""" + service1 = await get_llm_service() + service2 = await get_llm_service() + + # Should be the same instance due to lru_cache + assert service1 is service2 + + +class TestSingletonLLMService: + """Tests for _singleton_llm_service function""" + + def test_singleton_llm_service(self): + """Test singleton service creation""" + service = _singleton_llm_service() + + assert isinstance(service, LLMService) + + def test_singleton_cached(self): + """Test that singleton is cached""" + service1 = _singleton_llm_service() + service2 = _singleton_llm_service() + + # Should be the same instance due to lru_cache + assert service1 is service2 + + diff --git a/app/services/api/tests/services/test_llm_factory.py b/app/services/api/tests/services/test_llm_factory.py new file mode 100644 index 0000000..f4254e9 --- /dev/null +++ b/app/services/api/tests/services/test_llm_factory.py @@ -0,0 +1,68 @@ +""" +Tests for LLM factory +""" +import pytest +from pydantic import SecretStr +from finquest_api.services.llm.factory import build_llm_client +from finquest_api.services.llm.models import LLMError +from finquest_api.config import LLMSettings + + +class TestBuildLLMClient: + """Tests for build_llm_client function""" + + def test_build_gemini_client(self): + """Test building Gemini client""" + settings = LLMSettings( + provider="gemini", + model="gemini-2.0-flash", + api_key=SecretStr("test-key") + ) + + client = build_llm_client(settings) + + assert client is not None + from finquest_api.services.llm.providers.gemini import GeminiChatClient + assert isinstance(client, GeminiChatClient) + + def test_build_openai_client(self): + """Test building OpenAI client""" + settings = LLMSettings( + provider="openai", + model="gpt-4", + api_key=SecretStr("test-key") + ) + + client = build_llm_client(settings) + + assert client is not None + from finquest_api.services.llm.providers.openai import OpenAIChatClient + assert isinstance(client, OpenAIChatClient) + + def test_build_unsupported_provider(self): + """Test building with unsupported provider""" + settings = LLMSettings( + provider="unsupported", + model="test-model" + ) + + with pytest.raises(LLMError) as exc_info: + build_llm_client(settings) + + assert "Unsupported LLM provider" in str(exc_info.value) + + def test_build_case_insensitive_provider(self): + """Test provider matching is case insensitive""" + settings = LLMSettings( + provider="GEMINI", + model="gemini-2.0-flash", + api_key=SecretStr("test-key") + ) + + client = build_llm_client(settings) + + assert client is not None + from finquest_api.services.llm.providers.gemini import GeminiChatClient + assert isinstance(client, GeminiChatClient) + + diff --git a/app/services/api/tests/services/test_llm_service_extended.py b/app/services/api/tests/services/test_llm_service_extended.py new file mode 100644 index 0000000..3bb2c86 --- /dev/null +++ b/app/services/api/tests/services/test_llm_service_extended.py @@ -0,0 +1,38 @@ +""" +Extended tests for LLM service to cover missing line +""" +import pytest +from unittest.mock import Mock, AsyncMock +from pydantic import SecretStr + +from finquest_api.services.llm.service import LLMService +from finquest_api.services.llm.models import LLMCompletionRequest, LLMMessage +from finquest_api.config import LLMSettings + + +class TestLLMServiceExtended: + """Extended tests for LLMService""" + + @pytest.mark.anyio("asyncio") + async def test_acomplete_request(self): + """Test acomplete_request method (line 53)""" + settings = LLMSettings( + provider="gemini", + model="gemini-2.0-flash", + api_key=SecretStr("test-key"), + ) + + mock_client = AsyncMock() + mock_completion = Mock() + mock_client.acomplete.return_value = mock_completion + + service = LLMService(settings) + service._client = mock_client + + request = LLMCompletionRequest(messages=[LLMMessage(role="user", content="Hi")]) + result = await service.acomplete_request(request) + + assert result == mock_completion + mock_client.acomplete.assert_called_once_with(request) + + diff --git a/app/services/api/tests/services/test_llm_utils.py b/app/services/api/tests/services/test_llm_utils.py new file mode 100644 index 0000000..1025f58 --- /dev/null +++ b/app/services/api/tests/services/test_llm_utils.py @@ -0,0 +1,150 @@ +""" +Tests for LLM utils +""" +from pydantic import BaseModel +from finquest_api.services.llm.utils import get_gemini_compatible_schema, _dereference_schema + + +class SimpleModel(BaseModel): + """Simple test model""" + name: str + age: int + + +class NestedModel(BaseModel): + """Model with nested reference""" + user: SimpleModel + count: int + + +class TestGetGeminiCompatibleSchema: + """Tests for get_gemini_compatible_schema function""" + + def test_simple_model_no_defs(self): + """Test schema generation for simple model without $defs""" + schema = get_gemini_compatible_schema(SimpleModel) + + assert "properties" in schema + assert "name" in schema["properties"] + assert "age" in schema["properties"] + assert "$defs" not in schema + assert "definitions" not in schema + + def test_nested_model_with_defs(self): + """Test schema generation for nested model with $defs""" + schema = get_gemini_compatible_schema(NestedModel) + + # Should have inlined definitions + assert "$defs" not in schema + assert "definitions" not in schema + # The nested model should be inlined + assert "properties" in schema + assert "user" in schema["properties"] + + +class TestDereferenceSchema: + """Tests for _dereference_schema function""" + + def test_schema_without_defs(self): + """Test schema without definitions""" + schema = { + "type": "object", + "properties": { + "name": {"type": "string"} + } + } + + result = _dereference_schema(schema) + + assert result == schema + assert "$defs" not in result + + def test_schema_with_refs(self): + """Test schema with $ref references""" + schema = { + "type": "object", + "properties": { + "user": {"$ref": "#/$defs/SimpleModel"} + }, + "$defs": { + "SimpleModel": { + "type": "object", + "properties": { + "name": {"type": "string"} + } + } + } + } + + result = _dereference_schema(schema) + + assert "$defs" not in result + assert "$ref" not in str(result) + assert "properties" in result["properties"]["user"] + + def test_schema_with_definitions_key(self): + """Test schema with 'definitions' key instead of '$defs'""" + schema = { + "type": "object", + "properties": { + "user": {"$ref": "#/definitions/SimpleModel"} + }, + "definitions": { + "SimpleModel": { + "type": "object", + "properties": { + "name": {"type": "string"} + } + } + } + } + + result = _dereference_schema(schema) + + assert "definitions" not in result + assert "$ref" not in str(result) + + def test_nested_refs(self): + """Test schema with nested references""" + schema = { + "type": "object", + "properties": { + "outer": {"$ref": "#/$defs/Outer"} + }, + "$defs": { + "Outer": { + "type": "object", + "properties": { + "inner": {"$ref": "#/$defs/Inner"} + } + }, + "Inner": { + "type": "object", + "properties": { + "value": {"type": "string"} + } + } + } + } + + result = _dereference_schema(schema) + + assert "$defs" not in result + assert "$ref" not in str(result) + + def test_ref_to_missing_def(self): + """Test schema with reference to missing definition""" + schema = { + "type": "object", + "properties": { + "user": {"$ref": "#/$defs/MissingModel"} + }, + "$defs": {} + } + + result = _dereference_schema(schema) + + # Should leave the $ref as-is if definition is missing + assert "$ref" in str(result["properties"]["user"]) + + diff --git a/app/services/api/tests/services/test_openai_provider.py b/app/services/api/tests/services/test_openai_provider.py new file mode 100644 index 0000000..15c6acd --- /dev/null +++ b/app/services/api/tests/services/test_openai_provider.py @@ -0,0 +1,180 @@ +""" +Tests for OpenAI provider +""" +import pytest +from pydantic import SecretStr + +from finquest_api.services.llm.providers.openai import OpenAIChatClient +from finquest_api.services.llm.client_base import ProviderNotConfiguredError, ProviderRequestError +from finquest_api.services.llm.models import LLMCompletionRequest, LLMMessage +from finquest_api.config import LLMSettings + + +class DummyResponse: + """Lightweight httpx.Response stand-in for OpenAI""" + + def __init__(self, status_code=200, json_data=None, text=""): + self.status_code = status_code + self._json = json_data or {} + self.text = text + + def json(self): + return self._json + + +class DummyAsyncClient: + """Captures outgoing HTTP requests made by OpenAI provider""" + + response: DummyResponse = DummyResponse() + last_request = None + + def __init__(self, *args, **kwargs): + self._entered = False + + async def __aenter__(self): + self._entered = True + return self + + async def __aexit__(self, exc_type, exc, tb): + self._entered = False + return False + + async def post(self, url, json=None, headers=None): + DummyAsyncClient.last_request = { + "url": url, + "json": json, + "headers": headers, + } + return DummyAsyncClient.response + + +@pytest.mark.anyio("asyncio") +async def test_openai_client_requires_api_key(): + """Ensure missing API keys raise a helpful error""" + settings = LLMSettings(provider="openai", model="gpt-4") + client = OpenAIChatClient(settings) + request = LLMCompletionRequest(messages=[LLMMessage(role="user", content="Hi")]) + + with pytest.raises(ProviderNotConfiguredError): + await client.acomplete(request) + + +@pytest.mark.anyio("asyncio") +async def test_openai_client_sends_expected_payload(monkeypatch): + """Verify payload transformation and response parsing""" + response_body = { + "id": "chatcmpl-123", + "object": "chat.completion", + "created": 1677652288, + "model": "gpt-4", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Hello! How can I help you?" + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 10, + "completion_tokens": 7, + "total_tokens": 17 + } + } + DummyAsyncClient.response = DummyResponse(json_data=response_body) + monkeypatch.setattr( + "finquest_api.services.llm.providers.openai.httpx.AsyncClient", + DummyAsyncClient, + ) + + settings = LLMSettings( + provider="openai", + model="gpt-4", + api_key=SecretStr("test-key"), + base_url="https://api.openai.com/v1", + ) + client = OpenAIChatClient(settings) + request = LLMCompletionRequest( + messages=[ + LLMMessage(role="system", content="You are helpful."), + LLMMessage(role="user", content="Hi"), + ], + temperature=0.7, + max_output_tokens=100, + ) + + result = await client.acomplete(request) + + sent = DummyAsyncClient.last_request + assert sent["url"] == "/chat/completions" + assert sent["headers"]["Authorization"] == "Bearer test-key" + assert sent["json"]["model"] == "gpt-4" + assert sent["json"]["messages"][0]["role"] == "system" + assert sent["json"]["messages"][1]["role"] == "user" + assert sent["json"]["temperature"] == 0.7 + assert sent["json"]["max_tokens"] == 100 + + assert result.message.role == "assistant" + assert result.message.content == "Hello! How can I help you?" + assert result.usage.prompt_tokens == 10 + assert result.usage.completion_tokens == 7 + + +@pytest.mark.anyio("asyncio") +async def test_openai_client_handles_error_response(monkeypatch): + """Test error handling from OpenAI API""" + DummyAsyncClient.response = DummyResponse( + status_code=400, + text="Invalid request", + ) + monkeypatch.setattr( + "finquest_api.services.llm.providers.openai.httpx.AsyncClient", + DummyAsyncClient, + ) + + settings = LLMSettings( + provider="openai", + model="gpt-4", + api_key=SecretStr("test-key"), + ) + client = OpenAIChatClient(settings) + request = LLMCompletionRequest(messages=[LLMMessage(role="user", content="Hi")]) + + with pytest.raises(ProviderRequestError) as exc_info: + await client.acomplete(request) + + assert exc_info.value.status_code == 400 + assert "OpenAI API error" in str(exc_info.value) + + +@pytest.mark.anyio("asyncio") +async def test_openai_client_with_organization(monkeypatch): + """Test OpenAI client with organization header""" + response_body = { + "id": "chatcmpl-123", + "choices": [{"message": {"role": "assistant", "content": "OK"}}], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + } + DummyAsyncClient.response = DummyResponse(json_data=response_body) + monkeypatch.setattr( + "finquest_api.services.llm.providers.openai.httpx.AsyncClient", + DummyAsyncClient, + ) + + settings = LLMSettings( + provider="openai", + model="gpt-4", + api_key=SecretStr("test-key"), + organization="org-123", + ) + client = OpenAIChatClient(settings) + request = LLMCompletionRequest(messages=[LLMMessage(role="user", content="Hi")]) + + await client.acomplete(request) + + sent = DummyAsyncClient.last_request + assert sent["headers"]["OpenAI-Organization"] == "org-123" + + diff --git a/app/services/api/tests/services/test_openai_provider_extended.py b/app/services/api/tests/services/test_openai_provider_extended.py new file mode 100644 index 0000000..9b84518 --- /dev/null +++ b/app/services/api/tests/services/test_openai_provider_extended.py @@ -0,0 +1,215 @@ +""" +Extended tests for OpenAI provider to cover missing lines +""" +import pytest +from pydantic import SecretStr + +from finquest_api.services.llm.providers.openai import OpenAIChatClient +from finquest_api.services.llm.models import LLMCompletionRequest, LLMMessage, StructuredOutputConfig +from finquest_api.config import LLMSettings + + +class DummyResponse: + """Lightweight httpx.Response stand-in for OpenAI""" + + def __init__(self, status_code=200, json_data=None, text=""): + self.status_code = status_code + self._json = json_data or {} + self.text = text + + def json(self): + return self._json + + +class DummyAsyncClient: + """Captures outgoing HTTP requests made by OpenAI provider""" + + response: DummyResponse = DummyResponse() + last_request = None + + def __init__(self, *args, **kwargs): + self._entered = False + + async def __aenter__(self): + self._entered = True + return self + + async def __aexit__(self, exc_type, exc, tb): + self._entered = False + return False + + async def post(self, url, json=None, headers=None): + DummyAsyncClient.last_request = { + "url": url, + "json": json, + "headers": headers, + } + return DummyAsyncClient.response + + +@pytest.mark.anyio("asyncio") +async def test_openai_build_payload_with_user_identifier(monkeypatch): + """Test building payload with user_identifier (line 69)""" + response_body = { + "id": "chatcmpl-123", + "choices": [{"message": {"role": "assistant", "content": "OK"}}], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + } + DummyAsyncClient.response = DummyResponse(json_data=response_body) + monkeypatch.setattr( + "finquest_api.services.llm.providers.openai.httpx.AsyncClient", + DummyAsyncClient, + ) + + settings = LLMSettings( + provider="openai", + model="gpt-4", + api_key=SecretStr("test-key"), + ) + client = OpenAIChatClient(settings) + request = LLMCompletionRequest( + messages=[LLMMessage(role="user", content="Hi")], + user_identifier="user-123", + ) + + await client.acomplete(request) + + sent = DummyAsyncClient.last_request + assert sent["json"]["user"] == "user-123" + + +@pytest.mark.anyio("asyncio") +async def test_openai_build_payload_with_structured_output(monkeypatch): + """Test building payload with structured output (line 71)""" + response_body = { + "id": "chatcmpl-123", + "choices": [{"message": {"role": "assistant", "content": '{"answer": "Yes"}'}}], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + } + DummyAsyncClient.response = DummyResponse(json_data=response_body) + monkeypatch.setattr( + "finquest_api.services.llm.providers.openai.httpx.AsyncClient", + DummyAsyncClient, + ) + + settings = LLMSettings( + provider="openai", + model="gpt-4", + api_key=SecretStr("test-key"), + ) + client = OpenAIChatClient(settings) + structured = StructuredOutputConfig( + type="json_schema", + json_schema={"type": "object", "properties": {"answer": {"type": "string"}}}, + ) + request = LLMCompletionRequest( + messages=[LLMMessage(role="user", content="Answer yes/no")], + structured_output=structured, + ) + + await client.acomplete(request) + + sent = DummyAsyncClient.last_request + assert "response_format" in sent["json"] + assert sent["json"]["response_format"]["type"] == "json_schema" + + +@pytest.mark.anyio("asyncio") +async def test_openai_parse_completion_no_choices(monkeypatch): + """Test parsing completion when no choices returned (line 87)""" + response_body = { + "id": "chatcmpl-123", + "choices": [], + "usage": {"prompt_tokens": 1, "completion_tokens": 0, "total_tokens": 1}, + } + DummyAsyncClient.response = DummyResponse(json_data=response_body) + monkeypatch.setattr( + "finquest_api.services.llm.providers.openai.httpx.AsyncClient", + DummyAsyncClient, + ) + + settings = LLMSettings( + provider="openai", + model="gpt-4", + api_key=SecretStr("test-key"), + ) + client = OpenAIChatClient(settings) + request = LLMCompletionRequest(messages=[LLMMessage(role="user", content="Hi")]) + + from finquest_api.services.llm.client_base import ProviderRequestError + + with pytest.raises(ProviderRequestError) as exc_info: + await client.acomplete(request) + + assert "no completion choices" in str(exc_info.value).lower() + + +@pytest.mark.anyio("asyncio") +async def test_openai_parse_structured_output_success(monkeypatch): + """Test parsing structured output successfully (lines 105-108)""" + response_body = { + "id": "chatcmpl-123", + "choices": [{"message": {"role": "assistant", "content": '{"answer": "Yes"}'}}], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + } + DummyAsyncClient.response = DummyResponse(json_data=response_body) + monkeypatch.setattr( + "finquest_api.services.llm.providers.openai.httpx.AsyncClient", + DummyAsyncClient, + ) + + settings = LLMSettings( + provider="openai", + model="gpt-4", + api_key=SecretStr("test-key"), + ) + client = OpenAIChatClient(settings) + structured = StructuredOutputConfig( + type="json_schema", + json_schema={"type": "object", "properties": {"answer": {"type": "string"}}}, + ) + request = LLMCompletionRequest( + messages=[LLMMessage(role="user", content="Answer yes/no")], + structured_output=structured, + ) + + result = await client.acomplete(request) + + assert result.structured_output == {"answer": "Yes"} + + +@pytest.mark.anyio("asyncio") +async def test_openai_parse_structured_output_invalid_json(monkeypatch): + """Test parsing structured output with invalid JSON""" + response_body = { + "id": "chatcmpl-123", + "choices": [{"message": {"role": "assistant", "content": "Not valid JSON"}}], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + } + DummyAsyncClient.response = DummyResponse(json_data=response_body) + monkeypatch.setattr( + "finquest_api.services.llm.providers.openai.httpx.AsyncClient", + DummyAsyncClient, + ) + + settings = LLMSettings( + provider="openai", + model="gpt-4", + api_key=SecretStr("test-key"), + ) + client = OpenAIChatClient(settings) + structured = StructuredOutputConfig( + type="json_schema", + json_schema={"type": "object"}, + ) + request = LLMCompletionRequest( + messages=[LLMMessage(role="user", content="Answer")], + structured_output=structured, + ) + + result = await client.acomplete(request) + + # Should handle JSON decode error gracefully + assert result.structured_output is None + + diff --git a/app/services/api/tests/services/test_portfolio_service.py b/app/services/api/tests/services/test_portfolio_service.py new file mode 100644 index 0000000..10fd726 --- /dev/null +++ b/app/services/api/tests/services/test_portfolio_service.py @@ -0,0 +1,388 @@ +""" +Tests for portfolio service +""" +import pytest +from unittest.mock import Mock, MagicMock, patch +from uuid import uuid4 +from decimal import Decimal +from datetime import datetime, timezone + +from finquest_api.services.portfolio import ( + get_or_create_portfolio, + create_position_from_avg_cost, + _compute_positions, + get_portfolio_view, + Position, +) +from finquest_api.db.models import User, Portfolio, Instrument, Transaction + + +@pytest.fixture +def mock_user(): + """Create a mock user""" + user = Mock(spec=User) + user.id = uuid4() + user.base_currency = "USD" + return user + + +@pytest.fixture +def mock_db(): + """Create a mock database session""" + db = MagicMock() + return db + + +class TestGetOrCreatePortfolio: + """Tests for get_or_create_portfolio function""" + + def test_get_existing_portfolio(self, mock_user, mock_db): + """Test getting existing portfolio""" + mock_portfolio = Mock(spec=Portfolio) + mock_query = Mock() + mock_query.filter.return_value.first.return_value = mock_portfolio + mock_db.query.return_value = mock_query + + result = get_or_create_portfolio(mock_db, mock_user) + + assert result == mock_portfolio + mock_db.add.assert_not_called() + + def test_create_new_portfolio(self, mock_user, mock_db): + """Test creating new portfolio""" + mock_query = Mock() + mock_query.filter.return_value.first.return_value = None + mock_db.query.return_value = mock_query + + new_portfolio = Mock(spec=Portfolio) + new_portfolio.id = uuid4() + + with patch('finquest_api.services.portfolio.Portfolio', return_value=new_portfolio): + result = get_or_create_portfolio(mock_db, mock_user) + + assert result == new_portfolio + mock_db.add.assert_called_once() + mock_db.commit.assert_called_once() + + +class TestCreatePositionFromAvgCost: + """Tests for create_position_from_avg_cost function""" + + def test_create_position_success(self, mock_user, mock_db): + """Test successful position creation""" + mock_instrument = Mock(spec=Instrument) + mock_instrument.id = uuid4() + mock_instrument.currency = "USD" + + mock_portfolio = Mock(spec=Portfolio) + mock_portfolio.id = uuid4() + + mock_transaction = Mock(spec=Transaction) + mock_transaction.id = uuid4() + + with patch('finquest_api.services.portfolio.ensure_instrument', return_value=mock_instrument): + with patch('finquest_api.services.portfolio.get_or_create_portfolio', return_value=mock_portfolio): + with patch('finquest_api.services.portfolio.fx_at', return_value=None): + with patch('finquest_api.services.portfolio.Transaction', return_value=mock_transaction): + result = create_position_from_avg_cost( + mock_db, + mock_user, + "AAPL", + Decimal("10"), + Decimal("150.0") + ) + + assert len(result) == 1 + assert result[0] == mock_transaction.id + mock_db.add.assert_called_once() + mock_db.commit.assert_called_once() + + def test_create_position_with_fx_rate(self, mock_user, mock_db): + """Test position creation with FX rate""" + mock_instrument = Mock(spec=Instrument) + mock_instrument.id = uuid4() + mock_instrument.currency = "EUR" + + mock_portfolio = Mock(spec=Portfolio) + mock_portfolio.id = uuid4() + + mock_transaction = Mock(spec=Transaction) + mock_transaction.id = uuid4() + + with patch('finquest_api.services.portfolio.ensure_instrument', return_value=mock_instrument): + with patch('finquest_api.services.portfolio.get_or_create_portfolio', return_value=mock_portfolio): + with patch('finquest_api.services.portfolio.fx_at', return_value=Decimal("1.1")): + with patch('finquest_api.services.portfolio.Transaction', return_value=mock_transaction): + result = create_position_from_avg_cost( + mock_db, + mock_user, + "AAPL", + Decimal("10"), + Decimal("150.0") + ) + + assert len(result) == 1 + + def test_create_position_invalid_quantity(self, mock_user, mock_db): + """Test position creation with invalid quantity""" + with pytest.raises(ValueError) as exc_info: + create_position_from_avg_cost( + mock_db, + mock_user, + "AAPL", + Decimal("-10"), + Decimal("150.0") + ) + + assert "must be positive" in str(exc_info.value).lower() + + def test_create_position_invalid_cost(self, mock_user, mock_db): + """Test position creation with invalid cost""" + with pytest.raises(ValueError) as exc_info: + create_position_from_avg_cost( + mock_db, + mock_user, + "AAPL", + Decimal("10"), + Decimal("-150.0") + ) + + assert "must be positive" in str(exc_info.value).lower() + + def test_create_position_with_executed_at(self, mock_user, mock_db): + """Test position creation with executed_at timestamp""" + mock_instrument = Mock(spec=Instrument) + mock_instrument.id = uuid4() + mock_instrument.currency = "USD" + + mock_portfolio = Mock(spec=Portfolio) + mock_portfolio.id = uuid4() + + mock_transaction = Mock(spec=Transaction) + mock_transaction.id = uuid4() + + executed_at = datetime.now(timezone.utc) + + with patch('finquest_api.services.portfolio.ensure_instrument', return_value=mock_instrument): + with patch('finquest_api.services.portfolio.get_or_create_portfolio', return_value=mock_portfolio): + with patch('finquest_api.services.portfolio.fx_at', return_value=None): + with patch('finquest_api.services.portfolio.Transaction', return_value=mock_transaction): + result = create_position_from_avg_cost( + mock_db, + mock_user, + "AAPL", + Decimal("10"), + Decimal("150.0"), + executed_at=executed_at + ) + + assert len(result) == 1 + + +class TestComputePositions: + """Tests for _compute_positions function""" + + def test_compute_positions_buy(self, mock_db): + """Test computing positions with buy transaction""" + portfolio_id = uuid4() + instrument_id = uuid4() + + mock_transaction = Mock(spec=Transaction) + mock_transaction.instrument_id = instrument_id + mock_transaction.side = "buy" + mock_transaction.quantity = Decimal("10") + mock_transaction.price = Decimal("150.0") + mock_transaction.fx_rate_to_user_base = None + mock_transaction.executed_at = datetime.now(timezone.utc) + mock_transaction.deleted_at = None + + mock_query = Mock() + mock_query.filter.return_value.order_by.return_value.all.return_value = [mock_transaction] + mock_db.query.return_value = mock_query + + result = _compute_positions(mock_db, portfolio_id) + + assert instrument_id in result + assert result[instrument_id].quantity == Decimal("10") + assert result[instrument_id].avg_cost_trade_ccy == Decimal("150.0") + + def test_compute_positions_multiple_buys(self, mock_db): + """Test computing positions with multiple buy transactions""" + portfolio_id = uuid4() + instrument_id = uuid4() + + mock_tx1 = Mock(spec=Transaction) + mock_tx1.instrument_id = instrument_id + mock_tx1.side = "buy" + mock_tx1.quantity = Decimal("10") + mock_tx1.price = Decimal("150.0") + mock_tx1.fx_rate_to_user_base = None + mock_tx1.executed_at = datetime.now(timezone.utc) + mock_tx1.deleted_at = None + + mock_tx2 = Mock(spec=Transaction) + mock_tx2.instrument_id = instrument_id + mock_tx2.side = "buy" + mock_tx2.quantity = Decimal("5") + mock_tx2.price = Decimal("160.0") + mock_tx2.fx_rate_to_user_base = None + mock_tx2.executed_at = datetime.now(timezone.utc) + mock_tx2.deleted_at = None + + mock_query = Mock() + mock_query.filter.return_value.order_by.return_value.all.return_value = [mock_tx1, mock_tx2] + mock_db.query.return_value = mock_query + + result = _compute_positions(mock_db, portfolio_id) + + assert instrument_id in result + assert result[instrument_id].quantity == Decimal("15") + # Average cost: (10*150 + 5*160) / 15 = 153.33... + assert result[instrument_id].avg_cost_trade_ccy > Decimal("153") + + def test_compute_positions_sell(self, mock_db): + """Test computing positions with sell transaction""" + portfolio_id = uuid4() + instrument_id = uuid4() + + mock_buy = Mock(spec=Transaction) + mock_buy.instrument_id = instrument_id + mock_buy.side = "buy" + mock_buy.quantity = Decimal("10") + mock_buy.price = Decimal("150.0") + mock_buy.fx_rate_to_user_base = None + mock_buy.executed_at = datetime.now(timezone.utc) + mock_buy.deleted_at = None + + mock_sell = Mock(spec=Transaction) + mock_sell.instrument_id = instrument_id + mock_sell.side = "sell" + mock_sell.quantity = Decimal("5") + mock_sell.price = Decimal("160.0") + mock_sell.fx_rate_to_user_base = None + mock_sell.executed_at = datetime.now(timezone.utc) + mock_sell.deleted_at = None + + mock_query = Mock() + mock_query.filter.return_value.order_by.return_value.all.return_value = [mock_buy, mock_sell] + mock_db.query.return_value = mock_query + + result = _compute_positions(mock_db, portfolio_id) + + assert instrument_id in result + assert result[instrument_id].quantity == Decimal("5") + + def test_compute_positions_sell_all(self, mock_db): + """Test computing positions when all sold""" + portfolio_id = uuid4() + instrument_id = uuid4() + + mock_buy = Mock(spec=Transaction) + mock_buy.instrument_id = instrument_id + mock_buy.side = "buy" + mock_buy.quantity = Decimal("10") + mock_buy.price = Decimal("150.0") + mock_buy.fx_rate_to_user_base = None + mock_buy.executed_at = datetime.now(timezone.utc) + mock_buy.deleted_at = None + + mock_sell = Mock(spec=Transaction) + mock_sell.instrument_id = instrument_id + mock_sell.side = "sell" + mock_sell.quantity = Decimal("10") + mock_sell.price = Decimal("160.0") + mock_sell.fx_rate_to_user_base = None + mock_sell.executed_at = datetime.now(timezone.utc) + mock_sell.deleted_at = None + + mock_query = Mock() + mock_query.filter.return_value.order_by.return_value.all.return_value = [mock_buy, mock_sell] + mock_db.query.return_value = mock_query + + result = _compute_positions(mock_db, portfolio_id) + + # Position should be removed (zero quantity) + assert instrument_id not in result + + def test_compute_positions_with_fx_rate(self, mock_db): + """Test computing positions with FX rate""" + portfolio_id = uuid4() + instrument_id = uuid4() + + mock_transaction = Mock(spec=Transaction) + mock_transaction.instrument_id = instrument_id + mock_transaction.side = "buy" + mock_transaction.quantity = Decimal("10") + mock_transaction.price = Decimal("150.0") + mock_transaction.fx_rate_to_user_base = Decimal("1.1") + mock_transaction.executed_at = datetime.now(timezone.utc) + mock_transaction.deleted_at = None + + mock_query = Mock() + mock_query.filter.return_value.order_by.return_value.all.return_value = [mock_transaction] + mock_db.query.return_value = mock_query + + result = _compute_positions(mock_db, portfolio_id) + + assert instrument_id in result + assert result[instrument_id].cost_basis_base == Decimal("1650.0") # 10 * 150 * 1.1 + + +class TestGetPortfolioView: + """Tests for get_portfolio_view function""" + + def test_get_portfolio_view_empty(self, mock_user, mock_db): + """Test getting portfolio view for empty portfolio""" + mock_portfolio = Mock(spec=Portfolio) + mock_portfolio.id = uuid4() + + with patch('finquest_api.services.portfolio.get_or_create_portfolio', return_value=mock_portfolio): + with patch('finquest_api.services.portfolio._compute_positions', return_value={}): + result = get_portfolio_view(mock_db, mock_user) + + assert result.baseCurrency == "USD" + assert len(result.positions) == 0 + assert result.totals.totalValue == Decimal("0") + + def test_get_portfolio_view_with_positions(self, mock_user, mock_db): + """Test getting portfolio view with positions""" + mock_portfolio = Mock(spec=Portfolio) + mock_portfolio.id = uuid4() + + instrument_id = uuid4() + position = Position( + instrument_id=instrument_id, + quantity=Decimal("10"), + avg_cost_trade_ccy=Decimal("150.0"), + cost_basis_base=Decimal("1500.0") + ) + + mock_instrument = Mock(spec=Instrument) + mock_instrument.id = instrument_id + mock_instrument.symbol = "AAPL" + mock_instrument.name = "Apple Inc." + mock_instrument.type = "equity" + mock_instrument.currency = "USD" + mock_instrument.sector = "Technology" + + mock_price_record = Mock() + mock_price_record.price = Decimal("160.0") + mock_price_record.ts = datetime.now(timezone.utc) + mock_price_record.day_change_abs = Decimal("10.0") + mock_price_record.day_change_pct = Decimal("6.67") + + with patch('finquest_api.services.portfolio.get_or_create_portfolio', return_value=mock_portfolio): + with patch('finquest_api.services.portfolio._compute_positions', return_value={instrument_id: position}): + mock_query = Mock() + mock_query.filter.return_value.all.return_value = [mock_instrument] + mock_db.query.return_value = mock_query + + with patch('finquest_api.services.portfolio.get_latest_prices', return_value={instrument_id: mock_price_record}): + result = get_portfolio_view(mock_db, mock_user) + + assert result.baseCurrency == "USD" + assert len(result.positions) == 1 + assert result.positions[0].symbol == "AAPL" + assert result.totals.totalValue > Decimal("0") + + diff --git a/app/services/api/tests/services/test_pricing.py b/app/services/api/tests/services/test_pricing.py new file mode 100644 index 0000000..cef2adc --- /dev/null +++ b/app/services/api/tests/services/test_pricing.py @@ -0,0 +1,488 @@ +""" +Tests for pricing service +""" +import pytest +from unittest.mock import Mock, MagicMock, patch +from uuid import uuid4 +from decimal import Decimal +from datetime import date, datetime, timedelta, timezone + +from finquest_api.services.pricing import ( + get_latest_price, + get_latest_prices, + get_prev_close, + backfill_eod, + PriceRecord, +) +from finquest_api.db.models import Instrument, InstrumentPriceLatest, InstrumentPriceEOD + + +@pytest.fixture +def mock_db(): + """Create a mock database session""" + db = MagicMock() + return db + + +class TestGetLatestPrice: + """Tests for get_latest_price function""" + + def test_get_latest_price_no_instrument(self, mock_db): + """Test getting latest price when instrument doesn't exist""" + mock_query = Mock() + mock_query.filter.return_value.first.return_value = None + mock_db.query.return_value = mock_query + + result = get_latest_price(mock_db, uuid4()) + + assert result is None + + def test_get_latest_price_from_db_recent(self, mock_db): + """Test getting recent price from database""" + instrument_id = uuid4() + mock_instrument = Mock(spec=Instrument) + mock_instrument.id = instrument_id + mock_instrument.symbol = "AAPL" + + mock_latest_price = Mock(spec=InstrumentPriceLatest) + mock_latest_price.price = Decimal("150.0") + mock_latest_price.ts = datetime.now(timezone.utc) - timedelta(minutes=30) + mock_latest_price.day_change_abs = Decimal("5.0") + mock_latest_price.day_change_pct = Decimal("3.45") + + mock_instrument_query = Mock() + mock_instrument_query.filter.return_value.first.return_value = mock_instrument + + mock_price_query = Mock() + mock_price_query.filter.return_value.first.return_value = mock_latest_price + + mock_db.query.side_effect = [mock_instrument_query, mock_price_query] + + result = get_latest_price(mock_db, instrument_id) + + assert result is not None + assert result.price == Decimal("150.0") + assert result.day_change_abs == Decimal("5.0") + + def test_get_latest_price_from_yfinance(self, mock_db): + """Test getting price from yfinance when DB is stale""" + instrument_id = uuid4() + mock_instrument = Mock(spec=Instrument) + mock_instrument.id = instrument_id + mock_instrument.symbol = "AAPL" + + mock_latest_price = Mock(spec=InstrumentPriceLatest) + mock_latest_price.price = Decimal("150.0") + mock_latest_price.ts = datetime.now(timezone.utc) - timedelta(hours=2) + + mock_instrument_query = Mock() + mock_instrument_query.filter.return_value.first.return_value = mock_instrument + + mock_price_query = Mock() + mock_price_query.filter.return_value.first.return_value = mock_latest_price + + mock_db.query.side_effect = [mock_instrument_query, mock_price_query] + + # Mock yfinance response + mock_hist = Mock() + mock_hist.empty = False + mock_hist.__len__ = Mock(return_value=2) + + # Mock hist.iloc[-1] which returns a row with ["Close"] + mock_latest_row = Mock() + mock_latest_row.__getitem__ = Mock(return_value=160.0) + mock_hist_iloc = Mock() + mock_hist_iloc.__getitem__ = Mock(side_effect=lambda idx: mock_latest_row if idx == -1 else Mock(__getitem__=Mock(return_value=150.0))) + mock_hist.iloc = mock_hist_iloc + + mock_ticker = Mock() + mock_ticker.history.return_value = mock_hist + + with patch('finquest_api.services.pricing.yf.Ticker', return_value=mock_ticker): + result = get_latest_price(mock_db, instrument_id) + + assert result is not None + assert result.price == Decimal("160.0") + mock_db.commit.assert_called_once() + + def test_get_latest_price_yfinance_empty_fallback(self, mock_db): + """Test getting price falls back to EOD when yfinance is empty""" + instrument_id = uuid4() + mock_instrument = Mock(spec=Instrument) + mock_instrument.id = instrument_id + mock_instrument.symbol = "AAPL" + + mock_instrument_query = Mock() + mock_instrument_query.filter.return_value.first.return_value = mock_instrument + + mock_price_query = Mock() + mock_price_query.filter.return_value.first.return_value = None + + mock_db.query.side_effect = [mock_instrument_query, mock_price_query] + + mock_hist = Mock() + mock_hist.empty = True + + mock_ticker = Mock() + mock_ticker.history.return_value = mock_hist + + mock_eod_price = PriceRecord( + price=Decimal("155.0"), + ts=datetime.now(timezone.utc), + day_change_abs=None, + day_change_pct=None, + ) + + with patch('finquest_api.services.pricing.yf.Ticker', return_value=mock_ticker): + with patch('finquest_api.services.pricing.get_prev_close', return_value=mock_eod_price): + result = get_latest_price(mock_db, instrument_id) + + assert result == mock_eod_price + + def test_get_latest_price_exception_fallback(self, mock_db): + """Test getting price falls back to EOD on exception""" + instrument_id = uuid4() + mock_instrument = Mock(spec=Instrument) + mock_instrument.id = instrument_id + mock_instrument.symbol = "AAPL" + + mock_instrument_query = Mock() + mock_instrument_query.filter.return_value.first.return_value = mock_instrument + + mock_price_query = Mock() + mock_price_query.filter.return_value.first.return_value = None + + mock_db.query.side_effect = [mock_instrument_query, mock_price_query] + + mock_eod_price = PriceRecord( + price=Decimal("155.0"), + ts=datetime.now(timezone.utc), + day_change_abs=None, + day_change_pct=None, + ) + + with patch('finquest_api.services.pricing.yf.Ticker', side_effect=Exception("Network error")): + with patch('finquest_api.services.pricing.get_prev_close', return_value=mock_eod_price): + result = get_latest_price(mock_db, instrument_id) + + assert result == mock_eod_price + + +class TestGetLatestPrices: + """Tests for get_latest_prices function""" + + def test_get_latest_prices_multiple(self, mock_db): + """Test getting latest prices for multiple instruments""" + instrument_id1 = uuid4() + instrument_id2 = uuid4() + + mock_price1 = PriceRecord( + price=Decimal("150.0"), + ts=datetime.now(timezone.utc), + day_change_abs=Decimal("5.0"), + day_change_pct=Decimal("3.45"), + ) + + mock_price2 = PriceRecord( + price=Decimal("200.0"), + ts=datetime.now(timezone.utc), + day_change_abs=Decimal("10.0"), + day_change_pct=Decimal("5.26"), + ) + + with patch('finquest_api.services.pricing.get_latest_price') as mock_get_price: + mock_get_price.side_effect = [mock_price1, mock_price2] + + result = get_latest_prices(mock_db, [instrument_id1, instrument_id2]) + + assert len(result) == 2 + assert result[instrument_id1] == mock_price1 + assert result[instrument_id2] == mock_price2 + + +class TestGetPrevClose: + """Tests for get_prev_close function""" + + def test_get_prev_close_no_instrument(self, mock_db): + """Test getting previous close when instrument doesn't exist""" + mock_query = Mock() + mock_query.filter.return_value.first.return_value = None + mock_db.query.return_value = mock_query + + result = get_prev_close(mock_db, uuid4()) + + assert result is None + + def test_get_prev_close_from_db(self, mock_db): + """Test getting previous close from database""" + instrument_id = uuid4() + mock_instrument = Mock(spec=Instrument) + mock_instrument.id = instrument_id + mock_instrument.symbol = "AAPL" + + date.today() - timedelta(days=1) + mock_eod = Mock(spec=InstrumentPriceEOD) + mock_eod.close = Decimal("155.0") + + mock_instrument_query = Mock() + mock_instrument_query.filter.return_value.first.return_value = mock_instrument + + mock_eod_query = Mock() + mock_eod_query.filter.return_value.first.return_value = mock_eod + + mock_db.query.side_effect = [mock_instrument_query, mock_eod_query] + + result = get_prev_close(mock_db, instrument_id) + + assert result is not None + assert result.price == Decimal("155.0") + + def test_get_prev_close_from_yfinance(self, mock_db): + """Test getting previous close from yfinance""" + instrument_id = uuid4() + mock_instrument = Mock(spec=Instrument) + mock_instrument.id = instrument_id + mock_instrument.symbol = "AAPL" + + mock_instrument_query = Mock() + mock_instrument_query.filter.return_value.first.return_value = mock_instrument + + mock_eod_query = Mock() + mock_eod_query.filter.return_value.first.return_value = None + + mock_db.query.side_effect = [mock_instrument_query, mock_eod_query] + + # Mock yfinance response + mock_hist = Mock() + mock_hist.empty = False + mock_hist.__len__ = Mock(return_value=2) + + # Mock hist.index[-1].date() and hist.index[-2].date() + mock_index_item1 = Mock() + mock_index_item1.date.return_value = date.today() - timedelta(days=1) + mock_index_item2 = Mock() + mock_index_item2.date.return_value = date.today() - timedelta(days=2) + mock_hist.index = Mock() + mock_hist.index.__getitem__ = Mock(side_effect=lambda idx: mock_index_item1 if idx == -1 else mock_index_item2) + + # Mock hist.iloc[-1]["Close"] and hist.iloc[-2]["Close"] + mock_row1 = Mock() + mock_row1.__getitem__ = Mock(return_value=155.0) + mock_row2 = Mock() + mock_row2.__getitem__ = Mock(return_value=154.0) + mock_hist_iloc = Mock() + mock_hist_iloc.__getitem__ = Mock(side_effect=lambda idx: mock_row1 if idx == -1 else mock_row2) + mock_hist.iloc = mock_hist_iloc + + mock_ticker = Mock() + mock_ticker.history.return_value = mock_hist + + with patch('finquest_api.services.pricing.yf.Ticker', return_value=mock_ticker): + result = get_prev_close(mock_db, instrument_id) + + assert result is not None + assert result.price == Decimal("155.0") + mock_db.add.assert_called_once() + mock_db.commit.assert_called_once() + + def test_get_prev_close_yfinance_empty(self, mock_db): + """Test getting previous close when yfinance returns empty""" + instrument_id = uuid4() + mock_instrument = Mock(spec=Instrument) + mock_instrument.id = instrument_id + mock_instrument.symbol = "AAPL" + + mock_instrument_query = Mock() + mock_instrument_query.filter.return_value.first.return_value = mock_instrument + + mock_eod_query = Mock() + mock_eod_query.filter.return_value.first.return_value = None + + mock_db.query.side_effect = [mock_instrument_query, mock_eod_query] + + mock_hist = Mock() + mock_hist.empty = True + + mock_ticker = Mock() + mock_ticker.history.return_value = mock_hist + + with patch('finquest_api.services.pricing.yf.Ticker', return_value=mock_ticker): + result = get_prev_close(mock_db, instrument_id) + + assert result is None + + def test_get_prev_close_exception(self, mock_db): + """Test getting previous close with exception""" + instrument_id = uuid4() + mock_instrument = Mock(spec=Instrument) + mock_instrument.id = instrument_id + mock_instrument.symbol = "AAPL" + + mock_instrument_query = Mock() + mock_instrument_query.filter.return_value.first.return_value = mock_instrument + + mock_eod_query = Mock() + mock_eod_query.filter.return_value.first.return_value = None + + mock_db.query.side_effect = [mock_instrument_query, mock_eod_query] + + with patch('finquest_api.services.pricing.yf.Ticker', side_effect=Exception("Error")): + result = get_prev_close(mock_db, instrument_id) + + assert result is None + + +class TestBackfillEod: + """Tests for backfill_eod function""" + + def test_backfill_eod_no_instrument(self, mock_db): + """Test backfilling when instrument doesn't exist""" + mock_query = Mock() + mock_query.filter.return_value.first.return_value = None + mock_db.query.return_value = mock_query + + # Should return without error + backfill_eod(mock_db, uuid4(), date.today() - timedelta(days=7), date.today()) + + # Should not add anything + mock_db.add.assert_not_called() + + def test_backfill_eod_success(self, mock_db): + """Test successful EOD backfill""" + instrument_id = uuid4() + mock_instrument = Mock(spec=Instrument) + mock_instrument.id = instrument_id + mock_instrument.symbol = "AAPL" + + mock_query = Mock() + mock_query.filter.return_value.first.return_value = mock_instrument + mock_db.query.return_value = mock_query + + # Mock yfinance response - use Mock instead of pandas DataFrame + mock_hist = Mock() + mock_hist.empty = False + + # Mock iterrows to return date-indexed rows + row1_date = date.today() - timedelta(days=2) + row2_date = date.today() - timedelta(days=1) + + def mock_getitem(key): + if key == 'Open': + return Mock(__getitem__=Mock(side_effect=[150.0, 151.0])) + elif key == 'High': + return Mock(__getitem__=Mock(side_effect=[155.0, 156.0])) + elif key == 'Low': + return Mock(__getitem__=Mock(side_effect=[149.0, 150.0])) + elif key == 'Close': + return Mock(__getitem__=Mock(side_effect=[154.0, 155.0])) + elif key == 'Volume': + return Mock(__getitem__=Mock(side_effect=[1000000, 1100000])) + + mock_hist.__getitem__ = Mock(side_effect=mock_getitem) + + # Create mock rows + def create_row_data(idx, date_val): + row = Mock() + row.name = date_val + row.__getitem__ = Mock(side_effect=lambda k: { + 'Open': [150.0, 151.0][idx], + 'High': [155.0, 156.0][idx], + 'Low': [149.0, 150.0][idx], + 'Close': [154.0, 155.0][idx], + 'Volume': [1000000, 1100000][idx] + }[k]) + isna_mock = Mock() + isna_mock.__getitem__ = Mock(return_value=False) + row.isna = Mock(return_value=isna_mock) + return row + + mock_row1 = create_row_data(0, row1_date) + mock_row2 = create_row_data(1, row2_date) + + def create_mock_idx(dt_val): + """Create a mock index that has .date() method - dt_val is already a date""" + idx = Mock() + idx.date = Mock(return_value=dt_val) # dt_val is already a date object + return idx + + mock_idx1 = create_mock_idx(row1_date) + mock_idx2 = create_mock_idx(row2_date) + + mock_hist.iterrows.return_value = [ + (mock_idx1, mock_row1), + (mock_idx2, mock_row2), + ] + + mock_ticker = Mock() + mock_ticker.history.return_value = mock_hist + + mock_existing_query = Mock() + mock_existing_query.filter.return_value.first.return_value = None + + with patch('finquest_api.services.pricing.yf.Ticker', return_value=mock_ticker): + # Setup query side effect: instrument query, then existing queries for each date + query_calls = [mock_query] + for _ in range(2): + query_calls.append(mock_existing_query) + mock_db.query.side_effect = query_calls + + backfill_eod(mock_db, instrument_id, date.today() - timedelta(days=5), date.today()) + + # Should add prices + assert mock_db.add.call_count == 2 + mock_db.commit.assert_called_once() + + def test_backfill_eod_existing_prices(self, mock_db): + """Test backfilling when prices already exist""" + instrument_id = uuid4() + mock_instrument = Mock(spec=Instrument) + mock_instrument.id = instrument_id + mock_instrument.symbol = "AAPL" + + mock_query = Mock() + mock_query.filter.return_value.first.return_value = mock_instrument + mock_db.query.return_value = mock_query + + # Mock existing price + mock_existing = Mock(spec=InstrumentPriceEOD) + mock_existing_query = Mock() + mock_existing_query.filter.return_value.first.return_value = mock_existing + + import pandas as pd + dates = pd.date_range(start=date.today() - timedelta(days=5), end=date.today(), freq='D') + mock_hist = pd.DataFrame({ + 'Open': [150.0], + 'High': [155.0], + 'Low': [149.0], + 'Close': [154.0], + 'Volume': [1000000] + }, index=dates[:1]) + + mock_ticker = Mock() + mock_ticker.history.return_value = mock_hist + + with patch('finquest_api.services.pricing.yf.Ticker', return_value=mock_ticker): + mock_db.query.side_effect = [mock_query, mock_existing_query] + + backfill_eod(mock_db, instrument_id, date.today() - timedelta(days=5), date.today()) + + # Should not add existing prices + mock_db.add.assert_not_called() + + def test_backfill_eod_exception(self, mock_db): + """Test backfilling with exception""" + instrument_id = uuid4() + mock_instrument = Mock(spec=Instrument) + mock_instrument.id = instrument_id + mock_instrument.symbol = "AAPL" + + mock_query = Mock() + mock_query.filter.return_value.first.return_value = mock_instrument + mock_db.query.return_value = mock_query + + with patch('finquest_api.services.pricing.yf.Ticker', side_effect=Exception("Error")): + with pytest.raises(Exception): + backfill_eod(mock_db, instrument_id, date.today() - timedelta(days=5), date.today()) + + mock_db.rollback.assert_called_once() + diff --git a/app/services/api/tests/test_api_router.py b/app/services/api/tests/test_api_router.py new file mode 100644 index 0000000..b761a52 --- /dev/null +++ b/app/services/api/tests/test_api_router.py @@ -0,0 +1,19 @@ +""" +Tests for API router endpoints +""" +import pytest +from finquest_api.routers.api import api_root + + +class TestApiRoot: + """Tests for /api/v1/ endpoint""" + + @pytest.mark.anyio("asyncio") + async def test_api_root(self): + """Test API root endpoint""" + result = await api_root() + + assert result["message"] == "FinQuest API v1" + assert result["version"] == "0.1.0" + + diff --git a/app/services/api/tests/test_auth_router.py b/app/services/api/tests/test_auth_router.py new file mode 100644 index 0000000..6e8d827 --- /dev/null +++ b/app/services/api/tests/test_auth_router.py @@ -0,0 +1,225 @@ +""" +Additional tests for authentication router endpoints +""" +import pytest +from unittest.mock import Mock + +from finquest_api.routers.auth import ( + sign_up, sign_in, sign_out, refresh_token, get_me, google_sign_in +) +from finquest_api.routers.auth import SignUpRequest, SignInRequest, RefreshRequest, GoogleSignInRequest + + +class TestSignUp: + """Additional tests for signup endpoint""" + + @pytest.mark.anyio("asyncio") + async def test_signup_without_full_name(self, mock_supabase): + """Test signup without full_name""" + mock_user = Mock() + mock_user.id = "test-user-id" + mock_user.email = "test@example.com" + mock_user.user_metadata = {} + + mock_session = Mock() + mock_session.access_token = "test-access-token" + mock_session.refresh_token = "test-refresh-token" + mock_session.expires_in = 3600 + + mock_response = Mock() + mock_response.user = mock_user + mock_response.session = mock_session + + mock_supabase.auth.sign_up.return_value = mock_response + + request = SignUpRequest(email="test@example.com", password="password123") + result = await sign_up(request) + + assert result["access_token"] == "test-access-token" + assert result["user"]["email"] == "test@example.com" + + @pytest.mark.anyio("asyncio") + async def test_signup_generic_exception(self, mock_supabase): + """Test signup with generic exception""" + mock_supabase.auth.sign_up.side_effect = Exception("Generic error") + + request = SignUpRequest(email="test@example.com", password="password123") + + with pytest.raises(Exception) as exc_info: + await sign_up(request) + + assert exc_info.value.status_code == 400 + assert "Sign up failed" in exc_info.value.detail + + +class TestSignIn: + """Additional tests for signin endpoint""" + + @pytest.mark.anyio("asyncio") + async def test_signin_generic_exception(self, mock_supabase): + """Test signin with generic exception""" + mock_supabase.auth.sign_in_with_password.side_effect = Exception("Generic error") + + request = SignInRequest(email="test@example.com", password="password123") + + with pytest.raises(Exception) as exc_info: + await sign_in(request) + + assert exc_info.value.status_code == 401 + assert "Sign in failed" in exc_info.value.detail + + +class TestSignOut: + """Tests for signout endpoint""" + + @pytest.mark.anyio("asyncio") + async def test_signout_success(self, mock_supabase): + """Test successful signout""" + mock_user = Mock() + mock_supabase.auth.sign_out.return_value = None + + result = await sign_out(mock_user) + + assert result["message"] == "Successfully signed out" + mock_supabase.auth.sign_out.assert_called_once() + + @pytest.mark.anyio("asyncio") + async def test_signout_failure(self, mock_supabase): + """Test signout with exception""" + mock_user = Mock() + mock_supabase.auth.sign_out.side_effect = Exception("Sign out error") + + with pytest.raises(Exception) as exc_info: + await sign_out(mock_user) + + assert exc_info.value.status_code == 400 + assert "Sign out failed" in exc_info.value.detail + + +class TestRefreshToken: + """Additional tests for refresh token endpoint""" + + @pytest.mark.anyio("asyncio") + async def test_refresh_token_generic_exception(self, mock_supabase): + """Test refresh token with generic exception""" + mock_supabase.auth.refresh_session.side_effect = Exception("Generic error") + + request = RefreshRequest(refresh_token="old-refresh-token") + + with pytest.raises(Exception) as exc_info: + await refresh_token(request) + + assert exc_info.value.status_code == 401 + assert "Token refresh failed" in exc_info.value.detail + + +class TestGetMe: + """Tests for /me endpoint""" + + @pytest.mark.anyio("asyncio") + async def test_get_me_success(self, mock_supabase): + """Test successful get_me""" + mock_auth_user = Mock() + mock_auth_user.id = "test-user-id" + mock_auth_user.email = "test@example.com" + mock_auth_user.user_metadata = {"full_name": "Test User"} + mock_auth_user.created_at = "2024-01-01T00:00:00Z" + + mock_response = Mock() + mock_response.user = mock_auth_user + + mock_supabase.auth.get_user.return_value = mock_response + + mock_user = Mock() + result = await get_me(mock_user) + + assert result["id"] == "test-user-id" + assert result["email"] == "test@example.com" + assert result["full_name"] == "Test User" + + @pytest.mark.anyio("asyncio") + async def test_get_me_user_not_found(self, mock_supabase): + """Test get_me when user not found""" + mock_supabase.auth.get_user.return_value = None + + mock_user = Mock() + + with pytest.raises(Exception) as exc_info: + await get_me(mock_user) + + assert exc_info.value.status_code == 404 + assert "User not found" in exc_info.value.detail + + @pytest.mark.anyio("asyncio") + async def test_get_me_generic_exception(self, mock_supabase): + """Test get_me with generic exception""" + mock_supabase.auth.get_user.side_effect = Exception("Generic error") + + mock_user = Mock() + + with pytest.raises(Exception) as exc_info: + await get_me(mock_user) + + assert exc_info.value.status_code == 400 + assert "Failed to get user" in exc_info.value.detail + + +class TestGoogleSignIn: + """Tests for Google sign in endpoint""" + + @pytest.mark.anyio("asyncio") + async def test_google_sign_in_success(self, mock_supabase): + """Test successful Google sign in""" + mock_user = Mock() + mock_user.id = "test-user-id" + mock_user.email = "test@example.com" + mock_user.user_metadata = {"full_name": "Test User"} + + mock_session = Mock() + mock_session.access_token = "test-access-token" + mock_session.refresh_token = "test-refresh-token" + mock_session.expires_in = 3600 + + mock_response = Mock() + mock_response.user = mock_user + mock_response.session = mock_session + + mock_supabase.auth.sign_in_with_id_token.return_value = mock_response + + request = GoogleSignInRequest(id_token="google-id-token") + result = await google_sign_in(request) + + assert result["access_token"] == "test-access-token" + assert result["user"]["email"] == "test@example.com" + mock_supabase.auth.sign_in_with_id_token.assert_called_once() + + @pytest.mark.anyio("asyncio") + async def test_google_sign_in_failed(self, mock_supabase): + """Test Google sign in failure""" + mock_response = Mock() + mock_response.user = None + mock_response.session = None + + mock_supabase.auth.sign_in_with_id_token.return_value = mock_response + + request = GoogleSignInRequest(id_token="invalid-token") + + with pytest.raises(Exception) as exc_info: + await google_sign_in(request) + + assert exc_info.value.status_code == 401 + assert "Google sign in failed" in exc_info.value.detail + + @pytest.mark.anyio("asyncio") + async def test_google_sign_in_generic_exception(self, mock_supabase): + """Test Google sign in with generic exception""" + mock_supabase.auth.sign_in_with_id_token.side_effect = Exception("Generic error") + + request = GoogleSignInRequest(id_token="google-id-token") + + with pytest.raises(Exception) as exc_info: + await google_sign_in(request) + + assert exc_info.value.status_code == 401 + assert "Google sign in failed" in exc_info.value.detail + diff --git a/app/services/api/tests/test_auth_router_missing.py b/app/services/api/tests/test_auth_router_missing.py new file mode 100644 index 0000000..cb1d999 --- /dev/null +++ b/app/services/api/tests/test_auth_router_missing.py @@ -0,0 +1,39 @@ +""" +Tests for missing line in auth router +""" +import pytest +from unittest.mock import Mock, MagicMock + +from finquest_api.routers.auth import sign_up +from finquest_api.routers.auth import SignUpRequest + + +@pytest.fixture +def mock_supabase(monkeypatch): + """Replace Supabase client with a MagicMock""" + mock_client = MagicMock() + monkeypatch.setattr("finquest_api.supabase_client.supabase", mock_client) + monkeypatch.setattr("finquest_api.routers.auth.supabase", mock_client) + return mock_client + + +class TestSignUpMissingLine: + """Tests for missing line in sign_up""" + + @pytest.mark.anyio("asyncio") + async def test_signup_no_user_response(self, mock_supabase): + """Test signup when response.user is None (line 57)""" + mock_response = Mock() + mock_response.user = None + mock_response.session = None + + mock_supabase.auth.sign_up.return_value = mock_response + + request = SignUpRequest(email="test@example.com", password="password123") + + with pytest.raises(Exception) as exc_info: + await sign_up(request) + + assert exc_info.value.status_code == 400 + assert "Failed to create user" in exc_info.value.detail + diff --git a/app/services/api/tests/test_auth_utils.py b/app/services/api/tests/test_auth_utils.py new file mode 100644 index 0000000..d5b6249 --- /dev/null +++ b/app/services/api/tests/test_auth_utils.py @@ -0,0 +1,148 @@ +""" +Tests for authentication utilities +""" +import pytest +from unittest.mock import Mock, MagicMock, patch +from uuid import uuid4 +from jose import jwt +from fastapi import HTTPException +from fastapi.security import HTTPAuthorizationCredentials + +from finquest_api.auth_utils import verify_token, get_current_user +from finquest_api.config import settings +from finquest_api.db.models import User + + +@pytest.fixture +def mock_token_payload(): + """Create a mock JWT token payload""" + return { + "sub": str(uuid4()), + "email": "test@example.com", + "aud": "authenticated" + } + + +@pytest.fixture +def mock_user(): + """Create a mock user""" + user = Mock(spec=User) + user.id = uuid4() + user.auth_user_id = uuid4() + user.email = "test@example.com" + user.deleted_at = None + return user + + +class TestVerifyToken: + """Tests for verify_token function""" + + @pytest.mark.anyio("asyncio") + async def test_verify_token_success(self, mock_token_payload): + """Test successful token verification""" + token = jwt.encode( + mock_token_payload, + settings.SUPABASE_JWT_SECRET, + algorithm="HS256" + ) + + credentials = HTTPAuthorizationCredentials( + scheme="Bearer", + credentials=token + ) + + payload = await verify_token(credentials) + assert payload["sub"] == mock_token_payload["sub"] + assert payload["email"] == mock_token_payload["email"] + + @pytest.mark.anyio("asyncio") + async def test_verify_token_invalid(self): + """Test token verification with invalid token""" + credentials = HTTPAuthorizationCredentials( + scheme="Bearer", + credentials="invalid-token" + ) + + with pytest.raises(HTTPException) as exc_info: + await verify_token(credentials) + + assert exc_info.value.status_code == 401 + assert "Invalid authentication credentials" in exc_info.value.detail + + @pytest.mark.anyio("asyncio") + async def test_verify_token_wrong_secret(self, mock_token_payload): + """Test token verification with wrong secret""" + token = jwt.encode( + mock_token_payload, + "wrong-secret", + algorithm="HS256" + ) + + credentials = HTTPAuthorizationCredentials( + scheme="Bearer", + credentials=token + ) + + with pytest.raises(HTTPException) as exc_info: + await verify_token(credentials) + + assert exc_info.value.status_code == 401 + + +class TestGetCurrentUser: + """Tests for get_current_user function""" + + @pytest.mark.anyio("asyncio") + async def test_get_current_user_existing(self, mock_token_payload, mock_user): + """Test getting existing user from database""" + mock_db = MagicMock() + mock_db.query.return_value.filter.return_value.first.return_value = mock_user + + user = await get_current_user(mock_token_payload, mock_db) + + assert user == mock_user + mock_db.query.assert_called_once() + + @pytest.mark.anyio("asyncio") + async def test_get_current_user_create_new(self, mock_token_payload): + """Test creating new user when doesn't exist""" + mock_db = MagicMock() + mock_db.query.return_value.filter.return_value.first.return_value = None + + new_user = Mock(spec=User) + mock_db.add.return_value = None + mock_db.commit.return_value = None + mock_db.refresh.return_value = None + + with patch('finquest_api.auth_utils.User', return_value=new_user): + await get_current_user(mock_token_payload, mock_db) + + mock_db.add.assert_called_once() + mock_db.commit.assert_called_once() + mock_db.refresh.assert_called_once() + + @pytest.mark.anyio("asyncio") + async def test_get_current_user_missing_sub(self): + """Test get_current_user with missing sub in token""" + token_payload = {"email": "test@example.com"} + mock_db = MagicMock() + + with pytest.raises(HTTPException) as exc_info: + await get_current_user(token_payload, mock_db) + + assert exc_info.value.status_code == 401 + assert "Invalid token payload" in exc_info.value.detail + + @pytest.mark.anyio("asyncio") + async def test_get_current_user_invalid_uuid(self): + """Test get_current_user with invalid UUID format""" + token_payload = {"sub": "not-a-valid-uuid"} + mock_db = MagicMock() + + with pytest.raises(HTTPException) as exc_info: + await get_current_user(token_payload, mock_db) + + assert exc_info.value.status_code == 401 + assert "Invalid user ID format" in exc_info.value.detail + + diff --git a/app/services/api/tests/test_config.py b/app/services/api/tests/test_config.py new file mode 100644 index 0000000..b70d246 --- /dev/null +++ b/app/services/api/tests/test_config.py @@ -0,0 +1,106 @@ +""" +Tests for configuration settings +""" +from pydantic import SecretStr +from finquest_api.config import Settings, LLMSettings + + +class TestLLMSettings: + """Tests for LLMSettings model""" + + def test_llm_settings_defaults(self): + """Test LLMSettings with default values""" + settings = LLMSettings() + + assert settings.provider == "gemini" + assert settings.model == "gemini-2.0-flash" + assert settings.api_key is None + assert settings.default_timeout_seconds == 30.0 + assert settings.max_retries == 2 + + def test_llm_settings_custom(self): + """Test LLMSettings with custom values""" + settings = LLMSettings( + provider="openai", + model="gpt-4", + api_key=SecretStr("test-key"), + base_url="https://api.openai.com", + default_timeout_seconds=60.0, + max_retries=3 + ) + + assert settings.provider == "openai" + assert settings.model == "gpt-4" + assert settings.api_key.get_secret_value() == "test-key" + assert settings.base_url == "https://api.openai.com" + assert settings.default_timeout_seconds == 60.0 + assert settings.max_retries == 3 + + +class TestSettings: + """Tests for Settings model""" + + def test_settings_defaults(self): + """Test Settings with default values""" + settings = Settings() + + assert settings.API_NAME == "FinQuest API" + assert settings.API_VERSION == "0.1.0" + assert settings.DEBUG is True + assert settings.HOST == "0.0.0.0" + assert settings.PORT == 8000 + + def test_allowed_origins_list(self): + """Test allowed_origins_list property""" + settings = Settings(ALLOWED_ORIGINS="http://localhost:3000,http://localhost:3001") + + origins = settings.allowed_origins_list + assert len(origins) == 2 + assert "http://localhost:3000" in origins + assert "http://localhost:3001" in origins + + def test_allowed_origins_list_empty(self): + """Test allowed_origins_list with empty string""" + settings = Settings(ALLOWED_ORIGINS="") + + origins = settings.allowed_origins_list + assert origins == [] + + def test_allowed_origins_list_with_spaces(self): + """Test allowed_origins_list with spaces""" + settings = Settings(ALLOWED_ORIGINS=" http://localhost:3000 , http://localhost:3001 ") + + origins = settings.allowed_origins_list + assert len(origins) == 2 + assert "http://localhost:3000" in origins + assert "http://localhost:3001" in origins + + def test_allowed_origins_list_removes_trailing_slash(self): + """Test allowed_origins_list removes trailing slashes""" + settings = Settings(ALLOWED_ORIGINS="http://localhost:3000/,https://example.com/") + + origins = settings.allowed_origins_list + assert "http://localhost:3000" in origins + assert "https://example.com" in origins + + def test_llm_property(self): + """Test llm property returns LLMSettings""" + settings = Settings( + LLM_PROVIDER="openai", + LLM_MODEL="gpt-4", + LLM_API_KEY=SecretStr("test-key"), + LLM_BASE_URL="https://api.openai.com", + LLM_TIMEOUT_SECONDS=60.0, + LLM_MAX_RETRIES=3 + ) + + llm_settings = settings.llm + assert isinstance(llm_settings, LLMSettings) + assert llm_settings.provider == "openai" + assert llm_settings.model == "gpt-4" + assert llm_settings.api_key.get_secret_value() == "test-key" + assert llm_settings.base_url == "https://api.openai.com" + assert llm_settings.default_timeout_seconds == 60.0 + assert llm_settings.max_retries == 3 + + diff --git a/app/services/api/tests/test_db_session.py b/app/services/api/tests/test_db_session.py new file mode 100644 index 0000000..190be2a --- /dev/null +++ b/app/services/api/tests/test_db_session.py @@ -0,0 +1,152 @@ +""" +Tests for database session management +""" +import pytest +from unittest.mock import Mock, patch + +from finquest_api.db.session import get_engine, get_session, session_scope, init_database +from finquest_api.config import settings + + +class TestGetEngine: + """Tests for get_engine function""" + + def test_get_engine_success(self, monkeypatch): + """Test successful engine creation""" + mock_engine = Mock() + + with patch('finquest_api.db.session.create_engine', return_value=mock_engine): + monkeypatch.setenv("SUPABASE_DB_URL", "postgresql://test:test@localhost/test") + + # Reset global engine + import finquest_api.db.session as session_module + session_module.engine = None + + engine = get_engine() + assert engine == mock_engine + + def test_get_engine_missing_url(self, monkeypatch): + """Test engine creation without DB URL""" + monkeypatch.delenv("SUPABASE_DB_URL", raising=False) + monkeypatch.setattr(settings, "SUPABASE_DB_URL", None) + + # Reset global engine + import finquest_api.db.session as session_module + session_module.engine = None + + with pytest.raises(RuntimeError) as exc_info: + get_engine() + + assert "SUPABASE_DB_URL is not configured" in str(exc_info.value) + + def test_get_engine_postgresql_url_conversion(self, monkeypatch): + """Test postgresql:// URL is converted to postgresql+psycopg://""" + mock_engine = Mock() + + with patch('finquest_api.db.session.create_engine', return_value=mock_engine) as mock_create: + monkeypatch.setenv("SUPABASE_DB_URL", "postgresql://test:test@localhost/test") + + # Reset global engine + import finquest_api.db.session as session_module + session_module.engine = None + + get_engine() + + # Check that URL was converted + call_args = mock_create.call_args + assert "postgresql+psycopg://" in call_args[0][0] + + def test_get_engine_uses_cached_engine(self, monkeypatch): + """Test that get_engine returns cached engine on second call""" + mock_engine = Mock() + + with patch('finquest_api.db.session.create_engine', return_value=mock_engine) as mock_create: + monkeypatch.setenv("SUPABASE_DB_URL", "postgresql://test:test@localhost/test") + + # Reset global engine + import finquest_api.db.session as session_module + session_module.engine = None + + engine1 = get_engine() + engine2 = get_engine() + + # Should only create engine once + assert mock_create.call_count == 1 + assert engine1 == engine2 == mock_engine + + +class TestGetSession: + """Tests for get_session function""" + + def test_get_session_yields_and_closes(self, monkeypatch): + """Test that get_session yields session and closes it""" + mock_engine = Mock() + mock_session = Mock() + mock_sessionmaker = Mock(return_value=mock_session) + + with patch('finquest_api.db.session.get_engine', return_value=mock_engine): + with patch('finquest_api.db.session.SessionLocal', mock_sessionmaker): + session_gen = get_session() + session = next(session_gen) + + assert session == mock_session + mock_sessionmaker.assert_called_once_with(bind=mock_engine) + + # Close session + try: + next(session_gen) + except StopIteration: + pass + + mock_session.close.assert_called_once() + + +class TestSessionScope: + """Tests for session_scope context manager""" + + def test_session_scope_commits_on_success(self, monkeypatch): + """Test that session_scope commits on successful execution""" + mock_engine = Mock() + mock_session = Mock() + mock_sessionmaker = Mock(return_value=mock_session) + + with patch('finquest_api.db.session.get_engine', return_value=mock_engine): + with patch('finquest_api.db.session.SessionLocal', mock_sessionmaker): + with session_scope() as session: + assert session == mock_session + + mock_session.commit.assert_called_once() + mock_session.close.assert_called_once() + + def test_session_scope_rolls_back_on_exception(self, monkeypatch): + """Test that session_scope rolls back on exception""" + mock_engine = Mock() + mock_session = Mock() + mock_sessionmaker = Mock(return_value=mock_session) + + with patch('finquest_api.db.session.get_engine', return_value=mock_engine): + with patch('finquest_api.db.session.SessionLocal', mock_sessionmaker): + with pytest.raises(ValueError): + with session_scope(): + raise ValueError("Test error") + + mock_session.rollback.assert_called_once() + mock_session.close.assert_called_once() + mock_session.commit.assert_not_called() + + +class TestInitDatabase: + """Tests for init_database function""" + + def test_init_database_creates_tables(self, monkeypatch): + """Test that init_database creates tables""" + mock_engine = Mock() + mock_metadata = Mock() + + with patch('finquest_api.db.session.get_engine', return_value=mock_engine): + with patch('finquest_api.db.session.Base.metadata', mock_metadata): + init_database() + + mock_metadata.create_all.assert_called_once_with(bind=mock_engine) + + diff --git a/app/services/api/tests/test_gamification_router.py b/app/services/api/tests/test_gamification_router.py new file mode 100644 index 0000000..62daa85 --- /dev/null +++ b/app/services/api/tests/test_gamification_router.py @@ -0,0 +1,253 @@ +""" +Tests for gamification router endpoints +""" +import pytest +from unittest.mock import Mock, MagicMock, patch +from uuid import uuid4 +from datetime import datetime + +from finquest_api.routers.gamification import ( + handle_gamification_event, + get_gamification_state, + get_all_badges, + GamificationEventRequest, +) +from finquest_api.db.models import User, UserGamificationStats, BadgeDefinition + + +@pytest.fixture +def mock_user(): + """Create a mock user""" + user = Mock(spec=User) + user.id = uuid4() + return user + + +@pytest.fixture +def mock_stats(): + """Create mock gamification stats""" + stats = Mock(spec=UserGamificationStats) + stats.id = uuid4() + stats.user_id = uuid4() + stats.total_xp = 100 + stats.level = 1 + stats.current_streak = 0 + stats.total_modules_completed = 0 + stats.total_quizzes_completed = 0 + stats.total_portfolio_positions = 0 + stats.last_streak_date = None + return stats + + +@pytest.fixture +def mock_db(): + """Create a mock database session""" + db = MagicMock() + return db + + +class TestHandleGamificationEvent: + """Tests for /event endpoint""" + + @pytest.mark.anyio("asyncio") + async def test_login_event(self, mock_user, mock_stats, mock_db): + """Test login event""" + with patch('finquest_api.routers.gamification.get_or_create_stats', return_value=mock_stats): + with patch('finquest_api.routers.gamification.evaluate_badges', return_value=[]): + event = GamificationEventRequest(event_type="login") + result = await handle_gamification_event(event, mock_user, mock_db) + + assert result.xp_gained == 10 + assert result.total_xp == 110 + mock_db.commit.assert_called_once() + + @pytest.mark.anyio("asyncio") + async def test_module_completed_event(self, mock_user, mock_stats, mock_db): + """Test module completed event""" + with patch('finquest_api.routers.gamification.get_or_create_stats', return_value=mock_stats): + with patch('finquest_api.routers.gamification.evaluate_badges', return_value=[]): + with patch('finquest_api.routers.gamification.check_module_first_time', return_value=False): + event = GamificationEventRequest( + event_type="module_completed", + module_id=str(uuid4()) + ) + result = await handle_gamification_event(event, mock_user, mock_db) + + assert result.xp_gained == 25 + assert result.total_xp == 125 + assert mock_stats.total_modules_completed == 1 + + @pytest.mark.anyio("asyncio") + async def test_module_completed_first_time(self, mock_user, mock_stats, mock_db): + """Test module completed first time event""" + with patch('finquest_api.routers.gamification.get_or_create_stats', return_value=mock_stats): + with patch('finquest_api.routers.gamification.evaluate_badges', return_value=[]): + with patch('finquest_api.routers.gamification.check_module_first_time', return_value=True): + event = GamificationEventRequest( + event_type="module_completed", + module_id=str(uuid4()), + is_first_time_for_module=True + ) + result = await handle_gamification_event(event, mock_user, mock_db) + + assert result.xp_gained == 75 # 25 + 50 + + @pytest.mark.anyio("asyncio") + async def test_quiz_completed_high_score(self, mock_user, mock_stats, mock_db): + """Test quiz completed with high score""" + with patch('finquest_api.routers.gamification.get_or_create_stats', return_value=mock_stats): + with patch('finquest_api.routers.gamification.evaluate_badges', return_value=[]): + with patch('finquest_api.routers.gamification.update_streak', return_value=True): + event = GamificationEventRequest( + event_type="quiz_completed", + quiz_score=85.0, + quiz_completed_at=datetime.utcnow().isoformat() + ) + result = await handle_gamification_event(event, mock_user, mock_db) + + assert result.xp_gained >= 35 + assert mock_stats.total_quizzes_completed == 1 + + @pytest.mark.anyio("asyncio") + async def test_quiz_completed_low_score(self, mock_user, mock_stats, mock_db): + """Test quiz completed with low score""" + with patch('finquest_api.routers.gamification.get_or_create_stats', return_value=mock_stats): + with patch('finquest_api.routers.gamification.evaluate_badges', return_value=[]): + with patch('finquest_api.routers.gamification.update_streak', return_value=True): + event = GamificationEventRequest( + event_type="quiz_completed", + quiz_score=75.0, + quiz_completed_at=datetime.utcnow().isoformat() + ) + result = await handle_gamification_event(event, mock_user, mock_db) + + assert result.xp_gained >= 20 + + @pytest.mark.anyio("asyncio") + async def test_quiz_completed_below_threshold(self, mock_user, mock_stats, mock_db): + """Test quiz completed below passing threshold""" + with patch('finquest_api.routers.gamification.get_or_create_stats', return_value=mock_stats): + with patch('finquest_api.routers.gamification.evaluate_badges', return_value=[]): + event = GamificationEventRequest( + event_type="quiz_completed", + quiz_score=50.0 + ) + result = await handle_gamification_event(event, mock_user, mock_db) + + assert result.xp_gained == 0 + assert mock_stats.total_quizzes_completed == 0 + + @pytest.mark.anyio("asyncio") + async def test_portfolio_position_added(self, mock_user, mock_stats, mock_db): + """Test portfolio position added event""" + with patch('finquest_api.routers.gamification.get_or_create_stats', return_value=mock_stats): + with patch('finquest_api.routers.gamification.evaluate_badges', return_value=[]): + with patch('finquest_api.routers.gamification.get_portfolio_position_count', return_value=5): + event = GamificationEventRequest(event_type="portfolio_position_added") + result = await handle_gamification_event(event, mock_user, mock_db) + + assert result.xp_gained == 40 + assert mock_stats.total_portfolio_positions == 5 + + @pytest.mark.anyio("asyncio") + async def test_portfolio_position_updated(self, mock_user, mock_stats, mock_db): + """Test portfolio position updated event""" + with patch('finquest_api.routers.gamification.get_or_create_stats', return_value=mock_stats): + with patch('finquest_api.routers.gamification.evaluate_badges', return_value=[]): + event = GamificationEventRequest(event_type="portfolio_position_updated") + result = await handle_gamification_event(event, mock_user, mock_db) + + assert result.xp_gained == 20 + + @pytest.mark.anyio("asyncio") + async def test_level_up(self, mock_user, mock_stats, mock_db): + """Test level up scenario""" + mock_stats.total_xp = 150 + mock_stats.level = 1 + + with patch('finquest_api.routers.gamification.get_or_create_stats', return_value=mock_stats): + with patch('finquest_api.routers.gamification.evaluate_badges', return_value=[]): + with patch('finquest_api.routers.gamification.compute_level', return_value=2): + event = GamificationEventRequest(event_type="login") + result = await handle_gamification_event(event, mock_user, mock_db) + + assert result.level_up is True + + @pytest.mark.anyio("asyncio") + async def test_new_badges(self, mock_user, mock_stats, mock_db): + """Test new badges awarded""" + new_badges = [ + {"code": "first_module", "name": "First Module", "description": "Complete your first module"} + ] + + with patch('finquest_api.routers.gamification.get_or_create_stats', return_value=mock_stats): + with patch('finquest_api.routers.gamification.evaluate_badges', return_value=new_badges): + event = GamificationEventRequest(event_type="login") + result = await handle_gamification_event(event, mock_user, mock_db) + + assert len(result.new_badges) == 1 + assert result.new_badges[0].code == "first_module" + + +class TestGetGamificationState: + """Tests for /me endpoint""" + + @pytest.mark.anyio("asyncio") + async def test_get_gamification_state(self, mock_user, mock_stats, mock_db): + """Test getting gamification state""" + mock_badge = Mock(spec=BadgeDefinition) + mock_badge.code = "test_badge" + mock_badge.name = "Test Badge" + mock_badge.description = "Test description" + + mock_db.query.return_value.join.return_value.filter.return_value.all.return_value = [mock_badge] + + with patch('finquest_api.routers.gamification.get_or_create_stats', return_value=mock_stats): + result = await get_gamification_state(mock_user, mock_db) + + assert result.total_xp == 100 + assert result.level == 1 + assert len(result.badges) == 1 + + +class TestGetAllBadges: + """Tests for /badges endpoint""" + + @pytest.mark.anyio("asyncio") + async def test_get_all_badges(self, mock_user, mock_db): + """Test getting all badges""" + mock_badge1 = Mock(spec=BadgeDefinition) + mock_badge1.id = uuid4() + mock_badge1.code = "badge1" + mock_badge1.name = "Badge 1" + mock_badge1.description = "Description 1" + mock_badge1.category = "learning" + mock_badge1.is_active = True + + mock_badge2 = Mock(spec=BadgeDefinition) + mock_badge2.id = uuid4() + mock_badge2.code = "badge2" + mock_badge2.name = "Badge 2" + mock_badge2.description = "Description 2" + mock_badge2.category = "streak" + mock_badge2.is_active = True + + # Mock query for all badges + mock_query_all = Mock() + mock_query_all.all.return_value = [mock_badge1, mock_badge2] + + # Mock query for earned badges + mock_query_earned = Mock() + mock_query_earned.filter.return_value.all.return_value = [(mock_badge1.id,)] + + mock_db.query.side_effect = [mock_query_all, mock_query_earned] + + result = await get_all_badges(mock_user, mock_db) + + assert len(result) == 2 + assert result[0].code == "badge1" + assert result[0].earned is True + assert result[1].code == "badge2" + assert result[1].earned is False + + diff --git a/app/services/api/tests/test_gamification_router_extended.py b/app/services/api/tests/test_gamification_router_extended.py new file mode 100644 index 0000000..fb3921d --- /dev/null +++ b/app/services/api/tests/test_gamification_router_extended.py @@ -0,0 +1,115 @@ +""" +Extended tests for gamification router to cover missing lines +""" +import pytest +from unittest.mock import Mock, MagicMock, patch +from uuid import uuid4 +from datetime import datetime + +from finquest_api.routers.gamification import ( + handle_gamification_event, + GamificationEventRequest, +) +from finquest_api.db.models import User, UserGamificationStats + + +@pytest.fixture +def mock_user(): + """Create a mock user""" + user = Mock(spec=User) + user.id = uuid4() + return user + + +@pytest.fixture +def mock_stats(): + """Create mock gamification stats""" + stats = Mock(spec=UserGamificationStats) + stats.id = uuid4() + stats.user_id = uuid4() + stats.total_xp = 100 + stats.level = 1 + stats.current_streak = 0 + stats.total_modules_completed = 0 + stats.total_quizzes_completed = 0 + stats.total_portfolio_positions = 0 + stats.last_streak_date = None + return stats + + +@pytest.fixture +def mock_db(): + """Create a mock database session""" + db = MagicMock() + return db + + +class TestGamificationEventExtended: + """Extended tests to cover missing lines""" + + @pytest.mark.anyio("asyncio") + async def test_quiz_completed_invalid_date_format(self, mock_user, mock_stats, mock_db): + """Test quiz completed with invalid date format (line 95-96)""" + with patch('finquest_api.routers.gamification.get_or_create_stats', return_value=mock_stats): + with patch('finquest_api.routers.gamification.evaluate_badges', return_value=[]): + with patch('finquest_api.routers.gamification.update_streak', return_value=True): + event = GamificationEventRequest( + event_type="quiz_completed", + quiz_score=85.0, + quiz_completed_at="invalid-date-format" + ) + result = await handle_gamification_event(event, mock_user, mock_db) + + # Should use current date as fallback + assert result.xp_gained >= 35 + + @pytest.mark.anyio("asyncio") + async def test_module_completed_with_exception(self, mock_user, mock_stats, mock_db): + """Test module completed with exception in UUID parsing (line 111-112)""" + with patch('finquest_api.routers.gamification.get_or_create_stats', return_value=mock_stats): + with patch('finquest_api.routers.gamification.evaluate_badges', return_value=[]): + with patch('finquest_api.routers.gamification.check_module_first_time', side_effect=Exception("Error")): + event = GamificationEventRequest( + event_type="module_completed", + module_id="invalid-uuid" + ) + result = await handle_gamification_event(event, mock_user, mock_db) + + # Should handle exception and set is_first_time to False + assert result.xp_gained == 25 + + @pytest.mark.anyio("asyncio") + async def test_quiz_completed_no_date_provided(self, mock_user, mock_stats, mock_db): + """Test quiz completed without date (line 131)""" + with patch('finquest_api.routers.gamification.get_or_create_stats', return_value=mock_stats): + with patch('finquest_api.routers.gamification.evaluate_badges', return_value=[]): + with patch('finquest_api.routers.gamification.update_streak', return_value=True): + event = GamificationEventRequest( + event_type="quiz_completed", + quiz_score=85.0, + quiz_completed_at=None + ) + result = await handle_gamification_event(event, mock_user, mock_db) + + # Should use current date + assert result.xp_gained >= 35 + + @pytest.mark.anyio("asyncio") + async def test_quiz_completed_streak_not_incremented(self, mock_user, mock_stats, mock_db): + """Test quiz completed where streak doesn't increment (line 137)""" + mock_stats.current_streak = 5 # Same as previous + + with patch('finquest_api.routers.gamification.get_or_create_stats', return_value=mock_stats): + with patch('finquest_api.routers.gamification.evaluate_badges', return_value=[]): + with patch('finquest_api.routers.gamification.update_streak', return_value=False): + event = GamificationEventRequest( + event_type="quiz_completed", + quiz_score=85.0, + quiz_completed_at=datetime.utcnow().isoformat() + ) + result = await handle_gamification_event(event, mock_user, mock_db) + + # Should not add streak bonus + assert result.streak_incremented is False + + diff --git a/app/services/api/tests/test_gamification_router_missing_line.py b/app/services/api/tests/test_gamification_router_missing_line.py new file mode 100644 index 0000000..79fc728 --- /dev/null +++ b/app/services/api/tests/test_gamification_router_missing_line.py @@ -0,0 +1,73 @@ +""" +Tests for missing line in gamification router +""" +import pytest +from unittest.mock import Mock, MagicMock, patch +from uuid import uuid4 +from datetime import datetime + +from finquest_api.routers.gamification import handle_gamification_event, GamificationEventRequest +from finquest_api.db.models import User, UserGamificationStats + + +@pytest.fixture +def mock_user(): + """Create a mock user""" + user = Mock(spec=User) + user.id = uuid4() + return user + + +@pytest.fixture +def mock_stats(): + """Create mock gamification stats""" + stats = Mock(spec=UserGamificationStats) + stats.id = uuid4() + stats.user_id = uuid4() + stats.total_xp = 100 + stats.level = 1 + stats.current_streak = 0 + stats.total_modules_completed = 0 + stats.total_quizzes_completed = 0 + stats.total_portfolio_positions = 0 + stats.last_streak_date = None + return stats + + +@pytest.fixture +def mock_db(): + """Create a mock database session""" + db = MagicMock() + return db + + +class TestGamificationEventMissingLine: + """Tests for missing line in handle_gamification_event""" + + @pytest.mark.anyio("asyncio") + async def test_quiz_completed_streak_incremented_true(self, mock_user, mock_stats, mock_db): + """Test quiz completed when streak is incremented (line 137)""" + # Set initial streak + mock_stats.current_streak = 5 + + # Create a new stats object that will have increased streak after update_streak + def update_streak_side_effect(db, stats, quiz_date): + # Simulate streak increment + stats.current_streak = 6 + return True + + with patch('finquest_api.routers.gamification.get_or_create_stats', return_value=mock_stats): + with patch('finquest_api.routers.gamification.evaluate_badges', return_value=[]): + with patch('finquest_api.routers.gamification.update_streak', side_effect=update_streak_side_effect): + event = GamificationEventRequest( + event_type="quiz_completed", + quiz_score=85.0, + quiz_completed_at=datetime.utcnow().isoformat() + ) + result = await handle_gamification_event(event, mock_user, mock_db) + + # Should add streak bonus + assert result.streak_incremented is True + # XP should include streak bonus + assert result.xp_gained >= 35 + 2 # quiz + streak bonus + diff --git a/app/services/api/tests/test_gamification_service_missing.py b/app/services/api/tests/test_gamification_service_missing.py new file mode 100644 index 0000000..81ca621 --- /dev/null +++ b/app/services/api/tests/test_gamification_service_missing.py @@ -0,0 +1,235 @@ +""" +Tests for missing lines in gamification service +""" +from unittest.mock import Mock, MagicMock +from uuid import uuid4 + +from finquest_api.services.gamification import ( + get_xp_to_next_level, + evaluate_badges, +) +from finquest_api.db.models import UserGamificationStats, BadgeDefinition + + +class TestGetXpToNextLevelMissing: + """Tests for missing line in get_xp_to_next_level""" + + def test_get_xp_to_next_level_no_next_threshold(self): + """Test get_xp_to_next_level when next threshold is None (line 75)""" + # This tests the case where next_level_threshold is None + # This happens when level is already at max or beyond thresholds + result = get_xp_to_next_level(5000, 11) # Level beyond max + + assert result == 0 + + +class TestEvaluateBadgesMissing: + """Tests for missing lines in evaluate_badges""" + + def test_evaluate_badges_module_10(self): + """Test MODULE_10 badge evaluation (lines 164-168)""" + mock_db = MagicMock() + user_id = uuid4() + + mock_stats = Mock(spec=UserGamificationStats) + mock_stats.total_modules_completed = 10 + mock_stats.current_streak = 0 + mock_stats.total_portfolio_positions = 0 + + mock_badge = Mock(spec=BadgeDefinition) + mock_badge.id = uuid4() + mock_badge.code = "MODULE_10" + mock_badge.name = "10 Modules" + mock_badge.description = "Complete 10 modules" + mock_badge.is_active = True + + # Mock existing badges query (empty) + mock_existing_query = Mock() + mock_existing_query.join.return_value.filter.return_value.all.return_value = [] + + # Mock badge definition queries - MODULE_5 check first (returns None), then MODULE_10 + mock_badge_query_mod5 = Mock() + mock_badge_query_mod5.filter.return_value.first.return_value = None + + mock_badge_query_mod10 = Mock() + mock_badge_query_mod10.filter.return_value.first.return_value = mock_badge + + mock_db.query.side_effect = [mock_existing_query, mock_badge_query_mod5, mock_badge_query_mod10] + + result = evaluate_badges(mock_db, user_id, mock_stats) + + assert len(result) == 1 + assert result[0]["code"] == "MODULE_10" + + def test_evaluate_badges_module_20(self): + """Test MODULE_20 badge evaluation (lines 175-179)""" + mock_db = MagicMock() + user_id = uuid4() + + mock_stats = Mock(spec=UserGamificationStats) + mock_stats.total_modules_completed = 20 + mock_stats.current_streak = 0 + mock_stats.total_portfolio_positions = 0 + + mock_badge = Mock(spec=BadgeDefinition) + mock_badge.id = uuid4() + mock_badge.code = "MODULE_20" + mock_badge.name = "20 Modules" + mock_badge.description = "Complete 20 modules" + mock_badge.is_active = True + + # Mock existing badges query (empty) + mock_existing_query = Mock() + mock_existing_query.join.return_value.filter.return_value.all.return_value = [] + + # Mock badge definition queries - MODULE_5, MODULE_10 checks first (return None), then MODULE_20 + mock_badge_query_mod5 = Mock() + mock_badge_query_mod5.filter.return_value.first.return_value = None + + mock_badge_query_mod10 = Mock() + mock_badge_query_mod10.filter.return_value.first.return_value = None + + mock_badge_query_mod20 = Mock() + mock_badge_query_mod20.filter.return_value.first.return_value = mock_badge + + mock_db.query.side_effect = [mock_existing_query, mock_badge_query_mod5, mock_badge_query_mod10, mock_badge_query_mod20] + + result = evaluate_badges(mock_db, user_id, mock_stats) + + assert len(result) == 1 + assert result[0]["code"] == "MODULE_20" + + def test_evaluate_badges_streak_30(self): + """Test STREAK_30 badge evaluation (lines 198-202)""" + mock_db = MagicMock() + user_id = uuid4() + + mock_stats = Mock(spec=UserGamificationStats) + mock_stats.total_modules_completed = 0 + mock_stats.current_streak = 30 + mock_stats.total_portfolio_positions = 0 + + mock_badge = Mock(spec=BadgeDefinition) + mock_badge.id = uuid4() + mock_badge.code = "STREAK_30" + mock_badge.name = "30 Day Streak" + mock_badge.description = "30 day streak" + mock_badge.is_active = True + + # Mock existing badges query - include STREAK_7 so it's skipped + mock_existing_query = Mock() + mock_existing_query.join.return_value.filter.return_value.all.return_value = [("STREAK_7",)] # STREAK_7 already exists + + # Mock badge definition queries + # Since total_modules_completed=0, MODULE_5/10/20 checks are skipped + # Since STREAK_7 is in existing_codes, STREAK_7 check is skipped + # Only STREAK_30 will be checked + mock_badge_query_streak30 = Mock() + mock_badge_query_streak30.filter.return_value.first.return_value = mock_badge + + mock_db.query.side_effect = [ + mock_existing_query, + mock_badge_query_streak30 # Only STREAK_30 query (others skipped due to conditions) + ] + + result = evaluate_badges(mock_db, user_id, mock_stats) + + assert len(result) == 1 + assert result[0]["code"] == "STREAK_30" + + def test_evaluate_badges_diversifier(self): + """Test DIVERSIFIER badge evaluation (lines 221-225)""" + mock_db = MagicMock() + user_id = uuid4() + + mock_stats = Mock(spec=UserGamificationStats) + mock_stats.total_modules_completed = 0 + mock_stats.current_streak = 0 + mock_stats.total_portfolio_positions = 3 + + mock_badge = Mock(spec=BadgeDefinition) + mock_badge.id = uuid4() + mock_badge.code = "DIVERSIFIER" + mock_badge.name = "Diversifier" + mock_badge.description = "Add 3 positions" + mock_badge.is_active = True + + # Mock existing badges query - include PORTFOLIO_CREATOR so it's skipped + mock_existing_query = Mock() + mock_existing_query.join.return_value.filter.return_value.all.return_value = [("PORTFOLIO_CREATOR",)] # PORTFOLIO_CREATOR already exists + + # Mock badge definition queries + # Since total_modules_completed=0, MODULE_5/10/20 checks are skipped + # Since current_streak=0, STREAK_7/30 checks are skipped + # Since PORTFOLIO_CREATOR is in existing_codes, PORTFOLIO_CREATOR check is skipped + # Only DIVERSIFIER will be checked + mock_badge_query_diversifier = Mock() + mock_badge_query_diversifier.filter.return_value.first.return_value = mock_badge + + mock_db.query.side_effect = [ + mock_existing_query, + mock_badge_query_diversifier # Only DIVERSIFIER query (others skipped due to conditions) + ] + + result = evaluate_badges(mock_db, user_id, mock_stats) + + assert len(result) == 1 + assert result[0]["code"] == "DIVERSIFIER" + + def test_evaluate_badges_inactive_badge(self): + """Test badge evaluation when badge is inactive""" + mock_db = MagicMock() + user_id = uuid4() + + mock_stats = Mock(spec=UserGamificationStats) + mock_stats.total_modules_completed = 5 + mock_stats.current_streak = 0 + mock_stats.total_portfolio_positions = 0 + + mock_badge = Mock(spec=BadgeDefinition) + mock_badge.id = uuid4() + mock_badge.code = "MODULE_5" + mock_badge.name = "5 Modules" + mock_badge.description = "Complete 5 modules" + mock_badge.is_active = False # Inactive badge + + # Mock existing badges query (empty) + mock_existing_query = Mock() + mock_existing_query.join.return_value.filter.return_value.all.return_value = [] + + # Mock badge definition query + mock_badge_query = Mock() + mock_badge_query.filter.return_value.first.return_value = mock_badge + + mock_db.query.side_effect = [mock_existing_query, mock_badge_query] + + result = evaluate_badges(mock_db, user_id, mock_stats) + + # Should not award inactive badge + assert len(result) == 0 + + def test_evaluate_badges_badge_not_found(self): + """Test badge evaluation when badge definition not found""" + mock_db = MagicMock() + user_id = uuid4() + + mock_stats = Mock(spec=UserGamificationStats) + mock_stats.total_modules_completed = 5 + mock_stats.current_streak = 0 + mock_stats.total_portfolio_positions = 0 + + # Mock existing badges query (empty) + mock_existing_query = Mock() + mock_existing_query.join.return_value.filter.return_value.all.return_value = [] + + # Mock badge definition query returns None + mock_badge_query = Mock() + mock_badge_query.filter.return_value.first.return_value = None + + mock_db.query.side_effect = [mock_existing_query, mock_badge_query] + + result = evaluate_badges(mock_db, user_id, mock_stats) + + # Should not award badge if definition not found + assert len(result) == 0 + diff --git a/app/services/api/tests/test_health.py b/app/services/api/tests/test_health.py new file mode 100644 index 0000000..35907f8 --- /dev/null +++ b/app/services/api/tests/test_health.py @@ -0,0 +1,82 @@ +""" +Tests for health check endpoints +""" +import pytest +from unittest.mock import patch, MagicMock +from sqlalchemy.exc import SQLAlchemyError + +from finquest_api.routers.health import health_check, readiness_check + + +class TestHealthCheck: + """Tests for /health endpoint""" + + @pytest.mark.anyio("asyncio") + async def test_health_check_success(self): + """Test successful health check""" + result = await health_check() + + assert result["status"] == "healthy" + assert result["service"] == "finquest-api" + assert "timestamp" in result + + +class TestReadinessCheck: + """Tests for /ready endpoint""" + + @pytest.mark.anyio("asyncio") + async def test_readiness_check_success(self): + """Test successful readiness check""" + mock_engine = MagicMock() + mock_connection = MagicMock() + mock_connection.execute.return_value = None + mock_context = MagicMock() + mock_context.__enter__.return_value = mock_connection + mock_context.__exit__.return_value = None + mock_engine.connect.return_value = mock_context + + with patch('finquest_api.routers.health.get_engine', return_value=mock_engine): + result = await readiness_check() + + assert result["status"] == "ready" + assert result["checks"]["database"] == "ok" + assert "timestamp" in result + + @pytest.mark.anyio("asyncio") + async def test_readiness_check_runtime_error(self): + """Test readiness check with RuntimeError""" + mock_engine = MagicMock() + mock_connection = MagicMock() + mock_connection.execute.side_effect = RuntimeError("Configuration error") + mock_context = MagicMock() + mock_context.__enter__.return_value = mock_connection + mock_context.__exit__.return_value = None + mock_engine.connect.return_value = mock_context + + with patch('finquest_api.routers.health.get_engine', return_value=mock_engine): + with pytest.raises(Exception) as exc_info: + await readiness_check() + + # Should raise HTTPException with 503 status + assert exc_info.value.status_code == 503 + assert "configuration-error" in str(exc_info.value.detail) + + @pytest.mark.anyio("asyncio") + async def test_readiness_check_sqlalchemy_error(self): + """Test readiness check with SQLAlchemyError""" + mock_engine = MagicMock() + mock_connection = MagicMock() + mock_connection.execute.side_effect = SQLAlchemyError("Connection error") + mock_context = MagicMock() + mock_context.__enter__.return_value = mock_connection + mock_context.__exit__.return_value = None + mock_engine.connect.return_value = mock_context + + with patch('finquest_api.routers.health.get_engine', return_value=mock_engine): + with pytest.raises(Exception) as exc_info: + await readiness_check() + + # Should raise HTTPException with 503 status + assert exc_info.value.status_code == 503 + assert "connection-error" in str(exc_info.value.detail) + diff --git a/app/services/api/tests/test_init.py b/app/services/api/tests/test_init.py new file mode 100644 index 0000000..3e6f597 --- /dev/null +++ b/app/services/api/tests/test_init.py @@ -0,0 +1,27 @@ +""" +Tests for __init__.py main function +""" +from unittest.mock import patch +from finquest_api import main, __version__ + + +class TestMain: + """Tests for main function""" + + def test_main_function(self): + """Test main function calls uvicorn.run""" + with patch('finquest_api.uvicorn.run') as mock_run: + main() + + mock_run.assert_called_once() + call_args = mock_run.call_args + assert call_args[0][0] == "finquest_api.main:app" + assert call_args[1]["host"] == "0.0.0.0" + assert call_args[1]["port"] == 8000 + assert call_args[1]["reload"] is True + + def test_version(self): + """Test version constant""" + assert __version__ == "0.1.0" + + diff --git a/app/services/api/tests/test_modules_router.py b/app/services/api/tests/test_modules_router.py new file mode 100644 index 0000000..27b95d8 --- /dev/null +++ b/app/services/api/tests/test_modules_router.py @@ -0,0 +1,315 @@ +""" +Tests for modules router endpoints +""" +import pytest +from unittest.mock import Mock, MagicMock, patch, AsyncMock +from uuid import uuid4 + +from finquest_api.routers.modules import get_module, submit_module_attempt +from finquest_api.db.models import User, Module, ModuleVersion, ModuleQuestion, ModuleChoice, ModuleAttempt, ModuleCompletion +from finquest_api.schemas import ModuleAttemptRequest + + +@pytest.fixture +def mock_user(): + """Create a mock user""" + user = Mock(spec=User) + user.id = uuid4() + return user + + +@pytest.fixture +def mock_db(): + """Create a mock database session""" + db = MagicMock() + return db + + +class TestGetModule: + """Tests for GET /{module_id} endpoint""" + + @pytest.mark.anyio("asyncio") + async def test_get_module_success(self, mock_user, mock_db): + """Test successful module retrieval""" + module_id = str(uuid4()) + mock_module = Mock(spec=Module) + mock_module.id = uuid4() + mock_module.title = "Test Module" + + mock_version = Mock(spec=ModuleVersion) + mock_version.content_markdown = "# Test Content" + + mock_question = Mock(spec=ModuleQuestion) + mock_question.id = uuid4() + mock_question.prompt_markdown = "Test question?" + mock_question.explanation_markdown = "Explanation" + mock_question.order_index = 0 + + mock_choice = Mock(spec=ModuleChoice) + mock_choice.text_markdown = "Choice 1" + mock_choice.is_correct = True + + # Mock queries + mock_module_query = Mock() + mock_module_query.filter.return_value.first.return_value = mock_module + + mock_version_query = Mock() + mock_version_query.filter.return_value.order_by.return_value.first.return_value = mock_version + + mock_question_query = Mock() + mock_question_query.filter.return_value.order_by.return_value.all.return_value = [mock_question] + + mock_choice_query = Mock() + mock_choice_query.filter.return_value.all.return_value = [mock_choice] + + mock_db.query.side_effect = [ + mock_module_query, + mock_version_query, + mock_question_query, + mock_choice_query, + ] + + result = await get_module(module_id, mock_user, mock_db) + + assert result.title == "Test Module" + assert result.body == "# Test Content" + assert len(result.questions) == 1 + + @pytest.mark.anyio("asyncio") + async def test_get_module_not_found(self, mock_user, mock_db): + """Test module not found""" + module_id = str(uuid4()) + # Mock the query chain properly + mock_filter = Mock() + mock_filter.first.return_value = None + mock_query = Mock() + mock_query.filter.return_value = mock_filter + mock_db.query.return_value = mock_query + + with pytest.raises(Exception) as exc_info: + await get_module(module_id, mock_user, mock_db) + + assert exc_info.value.status_code == 404 + assert "Module not found" in str(exc_info.value.detail) + + @pytest.mark.anyio("asyncio") + async def test_get_module_no_version(self, mock_user, mock_db): + """Test module without version""" + module_id = str(uuid4()) + mock_module = Mock(spec=Module) + mock_module.id = uuid4() + + # Mock module query + mock_module_filter = Mock() + mock_module_filter.first.return_value = mock_module + mock_module_query = Mock() + mock_module_query.filter.return_value = mock_module_filter + + # Mock version query + mock_version_order_by = Mock() + mock_version_order_by.first.return_value = None + mock_version_filter = Mock() + mock_version_filter.order_by.return_value = mock_version_order_by + mock_version_query = Mock() + mock_version_query.filter.return_value = mock_version_filter + + mock_db.query.side_effect = [mock_module_query, mock_version_query] + + with pytest.raises(Exception) as exc_info: + await get_module(module_id, mock_user, mock_db) + + assert exc_info.value.status_code == 404 + assert "Module content not found" in str(exc_info.value.detail) + + @pytest.mark.anyio("asyncio") + async def test_get_module_invalid_id(self, mock_user, mock_db): + """Test invalid module ID""" + with pytest.raises(Exception) as exc_info: + await get_module("invalid-uuid", mock_user, mock_db) + + assert exc_info.value.status_code == 400 + + @pytest.mark.anyio("asyncio") + async def test_get_module_exception(self, mock_user, mock_db): + """Test exception handling""" + module_id = str(uuid4()) + mock_db.query.side_effect = Exception("Database error") + + with pytest.raises(Exception) as exc_info: + await get_module(module_id, mock_user, mock_db) + + assert exc_info.value.status_code == 500 + + +class TestSubmitModuleAttempt: + """Tests for POST /{module_id}/attempt endpoint""" + + @pytest.mark.anyio("asyncio") + async def test_submit_attempt_passed(self, mock_user, mock_db): + """Test submitting a passed attempt""" + module_id = str(uuid4()) + mock_background_tasks = Mock() + mock_suggestion_generator = AsyncMock() + + request = ModuleAttemptRequest( + score=85, + max_score=100, + passed=True, + answers={} + ) + + mock_attempt = Mock(spec=ModuleAttempt) + mock_attempt.id = uuid4() + + # Mock no existing completion + mock_completion_query = Mock() + mock_completion_query.filter.return_value.first.return_value = None + + # Mock no existing suggestion + mock_suggestion_query = Mock() + mock_suggestion_query.filter.return_value.first.return_value = None + + # Mock all suggestions query + mock_all_suggestions_query = Mock() + mock_all_suggestions_query.filter.return_value.all.return_value = [] + + mock_db.query.side_effect = [ + mock_completion_query, + mock_suggestion_query, + mock_all_suggestions_query, + ] + + with patch('finquest_api.routers.modules.ModuleAttempt', return_value=mock_attempt): + result = await submit_module_attempt( + module_id, + request, + mock_background_tasks, + mock_user, + mock_db, + mock_suggestion_generator + ) + + assert result.status == "ok" + assert result.completed is True + mock_db.add.assert_called() + mock_db.commit.assert_called_once() + + @pytest.mark.anyio("asyncio") + async def test_submit_attempt_failed(self, mock_user, mock_db): + """Test submitting a failed attempt""" + module_id = str(uuid4()) + mock_background_tasks = Mock() + mock_suggestion_generator = AsyncMock() + + request = ModuleAttemptRequest( + score=50, + max_score=100, + passed=False, + answers={} + ) + + mock_attempt = Mock(spec=ModuleAttempt) + mock_attempt.id = uuid4() + + with patch('finquest_api.routers.modules.ModuleAttempt', return_value=mock_attempt): + result = await submit_module_attempt( + module_id, + request, + mock_background_tasks, + mock_user, + mock_db, + mock_suggestion_generator + ) + + assert result.status == "ok" + assert result.completed is False + + @pytest.mark.anyio("asyncio") + async def test_submit_attempt_already_completed(self, mock_user, mock_db): + """Test submitting attempt when already completed""" + module_id = str(uuid4()) + mock_background_tasks = Mock() + mock_suggestion_generator = AsyncMock() + + request = ModuleAttemptRequest( + score=85, + max_score=100, + passed=True, + answers={} + ) + + mock_attempt = Mock(spec=ModuleAttempt) + mock_attempt.id = uuid4() + + mock_existing_completion = Mock(spec=ModuleCompletion) + mock_completion_query = Mock() + mock_completion_query.filter.return_value.first.return_value = mock_existing_completion + + mock_db.query.return_value = mock_completion_query + + with patch('finquest_api.routers.modules.ModuleAttempt', return_value=mock_attempt): + result = await submit_module_attempt( + module_id, + request, + mock_background_tasks, + mock_user, + mock_db, + mock_suggestion_generator + ) + + assert result.completed is False + + @pytest.mark.anyio("asyncio") + async def test_submit_attempt_invalid_id(self, mock_user, mock_db): + """Test invalid module ID""" + mock_background_tasks = Mock() + mock_suggestion_generator = AsyncMock() + + request = ModuleAttemptRequest( + score=85, + max_score=100, + passed=True, + answers={} + ) + + with pytest.raises(Exception) as exc_info: + await submit_module_attempt( + "invalid-uuid", + request, + mock_background_tasks, + mock_user, + mock_db, + mock_suggestion_generator + ) + + assert exc_info.value.status_code == 400 + + @pytest.mark.anyio("asyncio") + async def test_submit_attempt_exception(self, mock_user, mock_db): + """Test exception handling""" + module_id = str(uuid4()) + mock_background_tasks = Mock() + mock_suggestion_generator = AsyncMock() + + request = ModuleAttemptRequest( + score=85, + max_score=100, + passed=True, + answers={} + ) + + mock_db.add.side_effect = Exception("Database error") + + with pytest.raises(Exception) as exc_info: + await submit_module_attempt( + module_id, + request, + mock_background_tasks, + mock_user, + mock_db, + mock_suggestion_generator + ) + + assert exc_info.value.status_code == 500 + mock_db.rollback.assert_called_once() + diff --git a/app/services/api/tests/test_modules_router_attempt_extended.py b/app/services/api/tests/test_modules_router_attempt_extended.py new file mode 100644 index 0000000..fca9f1d --- /dev/null +++ b/app/services/api/tests/test_modules_router_attempt_extended.py @@ -0,0 +1,140 @@ +""" +Extended tests for module attempt endpoint to cover missing lines +""" +import pytest +from unittest.mock import Mock, MagicMock, patch, AsyncMock +from uuid import uuid4 + +from finquest_api.routers.modules import submit_module_attempt +from finquest_api.db.models import User, ModuleAttempt, Suggestion +from finquest_api.schemas import ModuleAttemptRequest + + +@pytest.fixture +def mock_user(): + """Create a mock user""" + user = Mock(spec=User) + user.id = uuid4() + return user + + +@pytest.fixture +def mock_db(): + """Create a mock database session""" + db = MagicMock() + return db + + +class TestSubmitModuleAttemptExtended: + """Extended tests for submit_module_attempt""" + + @pytest.mark.anyio("asyncio") + async def test_submit_attempt_all_suggestions_completed(self, mock_user, mock_db): + """Test submitting attempt when all suggestions are completed (lines 99-100, 117)""" + module_id = str(uuid4()) + mock_background_tasks = Mock() + mock_suggestion_generator = AsyncMock() + + request = ModuleAttemptRequest( + score=85, + max_score=100, + passed=True, + answers={} + ) + + mock_attempt = Mock(spec=ModuleAttempt) + mock_attempt.id = uuid4() + + # Mock existing completion check (no existing) + mock_completion_query = Mock() + mock_completion_query.filter.return_value.first.return_value = None + + # Mock suggestion update + mock_suggestion = Mock(spec=Suggestion) + mock_suggestion.status = "shown" + mock_suggestion_query = Mock() + mock_suggestion_query.filter.return_value.first.return_value = mock_suggestion + + # Mock all suggestions query - all completed + mock_all_suggestions = Mock(spec=Suggestion) + mock_all_suggestions.status = "completed" + mock_all_suggestions_query = Mock() + mock_all_suggestions_query.filter.return_value.all.return_value = [mock_all_suggestions] + + mock_db.query.side_effect = [ + mock_completion_query, + mock_suggestion_query, + mock_all_suggestions_query, + ] + + with patch('finquest_api.routers.modules.ModuleAttempt', return_value=mock_attempt): + result = await submit_module_attempt( + module_id, + request, + mock_background_tasks, + mock_user, + mock_db, + mock_suggestion_generator + ) + + assert result.status == "ok" + assert result.completed is True + # Should trigger background task since all suggestions are completed + mock_background_tasks.add_task.assert_called_once() + + @pytest.mark.anyio("asyncio") + async def test_submit_attempt_some_suggestions_not_completed(self, mock_user, mock_db): + """Test submitting attempt when some suggestions are not completed""" + module_id = str(uuid4()) + mock_background_tasks = Mock() + mock_suggestion_generator = AsyncMock() + + request = ModuleAttemptRequest( + score=85, + max_score=100, + passed=True, + answers={} + ) + + mock_attempt = Mock(spec=ModuleAttempt) + mock_attempt.id = uuid4() + + # Mock existing completion check (no existing) + mock_completion_query = Mock() + mock_completion_query.filter.return_value.first.return_value = None + + # Mock suggestion update + mock_suggestion = Mock(spec=Suggestion) + mock_suggestion.status = "shown" + mock_suggestion_query = Mock() + mock_suggestion_query.filter.return_value.first.return_value = mock_suggestion + + # Mock all suggestions query - mix of completed and shown + mock_suggestion1 = Mock(spec=Suggestion) + mock_suggestion1.status = "completed" + mock_suggestion2 = Mock(spec=Suggestion) + mock_suggestion2.status = "shown" + mock_all_suggestions_query = Mock() + mock_all_suggestions_query.filter.return_value.all.return_value = [mock_suggestion1, mock_suggestion2] + + mock_db.query.side_effect = [ + mock_completion_query, + mock_suggestion_query, + mock_all_suggestions_query, + ] + + with patch('finquest_api.routers.modules.ModuleAttempt', return_value=mock_attempt): + result = await submit_module_attempt( + module_id, + request, + mock_background_tasks, + mock_user, + mock_db, + mock_suggestion_generator + ) + + assert result.status == "ok" + # Should not trigger background task since not all are completed + mock_background_tasks.add_task.assert_not_called() + + diff --git a/app/services/api/tests/test_modules_router_extended.py b/app/services/api/tests/test_modules_router_extended.py new file mode 100644 index 0000000..7e03688 --- /dev/null +++ b/app/services/api/tests/test_modules_router_extended.py @@ -0,0 +1,73 @@ +""" +Extended tests for modules router to cover missing lines +""" +import pytest +from unittest.mock import Mock, MagicMock, patch, AsyncMock +from uuid import uuid4 + +from finquest_api.routers.modules import get_suggestion_generator, generate_suggestions_task +from finquest_api.db.models import User + + +class TestModuleDependencies: + """Tests for module dependencies""" + + def test_get_suggestion_generator(self): + """Test get_suggestion_generator dependency (lines 25-28)""" + generator = get_suggestion_generator() + + assert generator is not None + # Verify it creates the expected components + assert hasattr(generator, 'generate_suggestions_for_user') + + @pytest.mark.anyio("asyncio") + async def test_generate_suggestions_task_success(self): + """Test generate_suggestions_task background task (lines 30-43)""" + mock_generator = AsyncMock() + mock_user_obj = Mock(spec=User) + mock_user_obj.id = uuid4() + + mock_db = MagicMock() + mock_db.query.return_value.filter.return_value.first.return_value = mock_user_obj + + with patch('finquest_api.routers.modules.SessionLocal', return_value=mock_db): + with patch('finquest_api.routers.modules.get_engine', return_value=Mock()): + await generate_suggestions_task(mock_generator, str(mock_user_obj.id)) + + mock_generator.generate_suggestions_for_user.assert_called_once() + mock_db.close.assert_called_once() + + @pytest.mark.anyio("asyncio") + async def test_generate_suggestions_task_no_user(self): + """Test generate_suggestions_task when user not found""" + mock_generator = AsyncMock() + mock_db = MagicMock() + mock_db.query.return_value.filter.return_value.first.return_value = None + + with patch('finquest_api.routers.modules.SessionLocal', return_value=mock_db): + with patch('finquest_api.routers.modules.get_engine', return_value=Mock()): + # Should not raise exception + await generate_suggestions_task(mock_generator, str(uuid4())) + + mock_generator.generate_suggestions_for_user.assert_not_called() + mock_db.close.assert_called_once() + + @pytest.mark.anyio("asyncio") + async def test_generate_suggestions_task_exception(self): + """Test generate_suggestions_task with exception""" + mock_generator = AsyncMock() + mock_generator.generate_suggestions_for_user.side_effect = Exception("Error") + mock_user_obj = Mock(spec=User) + mock_user_obj.id = uuid4() + + mock_db = MagicMock() + mock_db.query.return_value.filter.return_value.first.return_value = mock_user_obj + + with patch('finquest_api.routers.modules.SessionLocal', return_value=mock_db): + with patch('finquest_api.routers.modules.get_engine', return_value=Mock()): + # Should handle exception gracefully + await generate_suggestions_task(mock_generator, str(mock_user_obj.id)) + + mock_db.close.assert_called_once() + + diff --git a/app/services/api/tests/test_portfolio_router.py b/app/services/api/tests/test_portfolio_router.py new file mode 100644 index 0000000..7b10814 --- /dev/null +++ b/app/services/api/tests/test_portfolio_router.py @@ -0,0 +1,354 @@ +""" +Tests for portfolio router endpoints +""" +import pytest +from unittest.mock import Mock, MagicMock, patch +from uuid import uuid4 +from datetime import date, datetime, timedelta, timezone + +from finquest_api.routers.portfolio import ( + add_position, + get_portfolio, + get_snapshots, + generate_snapshot, +) +from finquest_api.db.models import User, Portfolio, PortfolioValuationSnapshot +from finquest_api.schemas import PostPositionRequest + + +@pytest.fixture +def mock_user(): + """Create a mock user""" + user = Mock(spec=User) + user.id = uuid4() + user.base_currency = "USD" + return user + + +@pytest.fixture +def mock_db(): + """Create a mock database session""" + db = MagicMock() + return db + + +class TestAddPosition: + """Tests for POST /portfolio/positions endpoint""" + + @pytest.mark.anyio("asyncio") + async def test_add_position_success(self, mock_user, mock_db): + """Test successful position addition""" + mock_portfolio = Mock(spec=Portfolio) + mock_portfolio.id = uuid4() + + request = PostPositionRequest( + symbol="AAPL", + quantity=10, + avgCost=150.0 + ) + + with patch('finquest_api.routers.portfolio.create_position_from_avg_cost', return_value=[uuid4()]) as mock_create: + with patch('finquest_api.routers.portfolio.get_or_create_portfolio', return_value=mock_portfolio): + with patch('finquest_api.routers.portfolio.recalculate_snapshots_after_transaction'): + result = await add_position(request, mock_user, mock_db) + + assert result.status == "ok" + assert result.portfolioId == str(mock_portfolio.id) + assert len(result.transactionIds) == 1 + mock_create.assert_called_once() + + @pytest.mark.anyio("asyncio") + async def test_add_position_with_executed_at(self, mock_user, mock_db): + """Test position addition with executed_at timestamp""" + mock_portfolio = Mock(spec=Portfolio) + mock_portfolio.id = uuid4() + + executed_at = datetime.now(timezone.utc) + request = PostPositionRequest( + symbol="AAPL", + quantity=10, + avgCost=150.0, + executedAt=executed_at + ) + + with patch('finquest_api.routers.portfolio.create_position_from_avg_cost', return_value=[uuid4()]): + with patch('finquest_api.routers.portfolio.get_or_create_portfolio', return_value=mock_portfolio): + with patch('finquest_api.routers.portfolio.recalculate_snapshots_after_transaction') as mock_recalc: + result = await add_position(request, mock_user, mock_db) + + assert result.status == "ok" + mock_recalc.assert_called_once() + + @pytest.mark.anyio("asyncio") + async def test_add_position_invalid_quantity(self, mock_user, mock_db): + """Test position addition with invalid quantity - Pydantic validation""" + # Pydantic validation catches negative values before reaching endpoint + from pydantic import ValidationError + + with pytest.raises(ValidationError): + PostPositionRequest( + symbol="AAPL", + quantity=-10, + avgCost=150.0 + ) + + @pytest.mark.anyio("asyncio") + async def test_add_position_invalid_cost(self, mock_user, mock_db): + """Test position addition with invalid average cost - Pydantic validation""" + # Pydantic validation catches negative values before reaching endpoint + from pydantic import ValidationError + + with pytest.raises(ValidationError): + PostPositionRequest( + symbol="AAPL", + quantity=10, + avgCost=-150.0 + ) + + @pytest.mark.anyio("asyncio") + async def test_add_position_endpoint_validation(self, mock_user, mock_db): + """Test endpoint-level validation for edge cases""" + # Test that endpoint validates quantity > 0 (even though Pydantic does too) + # We'll test with a valid request to ensure endpoint logic works + mock_portfolio = Mock(spec=Portfolio) + mock_portfolio.id = uuid4() + + request = PostPositionRequest( + symbol="AAPL", + quantity=0.0001, # Very small but positive + avgCost=150.0 + ) + + with patch('finquest_api.routers.portfolio.create_position_from_avg_cost', return_value=[uuid4()]): + with patch('finquest_api.routers.portfolio.get_or_create_portfolio', return_value=mock_portfolio): + with patch('finquest_api.routers.portfolio.recalculate_snapshots_after_transaction'): + result = await add_position(request, mock_user, mock_db) + assert result.status == "ok" + + @pytest.mark.anyio("asyncio") + async def test_add_position_value_error(self, mock_user, mock_db): + """Test position addition with ValueError""" + request = PostPositionRequest( + symbol="INVALID", + quantity=10, + avgCost=150.0 + ) + + with patch('finquest_api.routers.portfolio.create_position_from_avg_cost', side_effect=ValueError("Symbol not found")): + with pytest.raises(Exception) as exc_info: + await add_position(request, mock_user, mock_db) + + assert exc_info.value.status_code == 404 + + @pytest.mark.anyio("asyncio") + async def test_add_position_recalculation_failure(self, mock_user, mock_db): + """Test position addition when recalculation fails""" + mock_portfolio = Mock(spec=Portfolio) + mock_portfolio.id = uuid4() + + request = PostPositionRequest( + symbol="AAPL", + quantity=10, + avgCost=150.0 + ) + + with patch('finquest_api.routers.portfolio.create_position_from_avg_cost', return_value=[uuid4()]): + with patch('finquest_api.routers.portfolio.get_or_create_portfolio', return_value=mock_portfolio): + with patch('finquest_api.routers.portfolio.recalculate_snapshots_after_transaction', side_effect=Exception("Recalc error")): + # Should not fail even if recalculation fails + result = await add_position(request, mock_user, mock_db) + assert result.status == "ok" + + +class TestGetPortfolio: + """Tests for GET /portfolio endpoint""" + + @pytest.mark.anyio("asyncio") + async def test_get_portfolio_success(self, mock_user, mock_db): + """Test successful portfolio retrieval""" + mock_response = Mock() + + with patch('finquest_api.routers.portfolio.get_portfolio_view', return_value=mock_response): + result = await get_portfolio(mock_user, mock_db) + + assert result == mock_response + + @pytest.mark.anyio("asyncio") + async def test_get_portfolio_exception(self, mock_user, mock_db): + """Test portfolio retrieval with exception""" + with patch('finquest_api.routers.portfolio.get_portfolio_view', side_effect=Exception("Database error")): + with pytest.raises(Exception) as exc_info: + await get_portfolio(mock_user, mock_db) + + assert exc_info.value.status_code == 500 + assert "Failed to get portfolio" in str(exc_info.value.detail) + + +class TestGetSnapshots: + """Tests for GET /portfolio/snapshots endpoint""" + + @pytest.mark.anyio("asyncio") + async def test_get_snapshots_default_range(self, mock_user, mock_db): + """Test getting snapshots with default date range""" + mock_portfolio = Mock(spec=Portfolio) + mock_portfolio.id = uuid4() + + mock_snapshot = Mock(spec=PortfolioValuationSnapshot) + mock_snapshot.as_of = datetime.now(timezone.utc) + mock_snapshot.total_value = 1000.0 + + mock_query = Mock() + mock_query.filter.return_value.order_by.return_value.all.return_value = [mock_snapshot] + mock_db.query.return_value = mock_query + + with patch('finquest_api.routers.portfolio.get_or_create_portfolio', return_value=mock_portfolio): + result = await get_snapshots(None, None, None, mock_user, mock_db) + + assert result.baseCurrency == "USD" + assert len(result.series) == 1 + + @pytest.mark.anyio("asyncio") + async def test_get_snapshots_with_dates(self, mock_user, mock_db): + """Test getting snapshots with specific date range""" + mock_portfolio = Mock(spec=Portfolio) + mock_portfolio.id = uuid4() + + mock_snapshot = Mock(spec=PortfolioValuationSnapshot) + mock_snapshot.as_of = datetime.now(timezone.utc) + mock_snapshot.total_value = 1000.0 + + mock_query = Mock() + mock_query.filter.return_value.order_by.return_value.all.return_value = [mock_snapshot] + mock_db.query.return_value = mock_query + + from_date = date.today() - timedelta(days=30) + to_date = date.today() + + with patch('finquest_api.routers.portfolio.get_or_create_portfolio', return_value=mock_portfolio): + with patch('finquest_api.routers.portfolio.ensure_snapshots_for_range'): + result = await get_snapshots(from_date, to_date, None, mock_user, mock_db) + + assert result.baseCurrency == "USD" + assert len(result.series) == 1 + + @pytest.mark.anyio("asyncio") + async def test_get_snapshots_hourly_granularity(self, mock_user, mock_db): + """Test getting snapshots with hourly granularity""" + mock_portfolio = Mock(spec=Portfolio) + mock_portfolio.id = uuid4() + + now = datetime.now(timezone.utc) + mock_snapshot1 = Mock(spec=PortfolioValuationSnapshot) + mock_snapshot1.as_of = now + mock_snapshot1.total_value = 1000.0 + + mock_snapshot2 = Mock(spec=PortfolioValuationSnapshot) + mock_snapshot2.as_of = now + timedelta(hours=2) + mock_snapshot2.total_value = 1100.0 + + mock_query = Mock() + mock_query.filter.return_value.order_by.return_value.all.return_value = [mock_snapshot1, mock_snapshot2] + mock_db.query.return_value = mock_query + + with patch('finquest_api.routers.portfolio.get_or_create_portfolio', return_value=mock_portfolio): + with patch('finquest_api.routers.portfolio.ensure_snapshots_for_range'): + result = await get_snapshots(None, None, "hourly", mock_user, mock_db) + + assert len(result.series) >= 1 + + @pytest.mark.anyio("asyncio") + async def test_get_snapshots_daily_granularity(self, mock_user, mock_db): + """Test getting snapshots with daily granularity""" + mock_portfolio = Mock(spec=Portfolio) + mock_portfolio.id = uuid4() + + today = datetime.now(timezone.utc) + yesterday = today - timedelta(days=1) + + mock_snapshot1 = Mock(spec=PortfolioValuationSnapshot) + mock_snapshot1.as_of = yesterday + mock_snapshot1.total_value = 1000.0 + + mock_snapshot2 = Mock(spec=PortfolioValuationSnapshot) + mock_snapshot2.as_of = today + mock_snapshot2.total_value = 1100.0 + + mock_query = Mock() + mock_query.filter.return_value.order_by.return_value.all.return_value = [mock_snapshot1, mock_snapshot2] + mock_db.query.return_value = mock_query + + with patch('finquest_api.routers.portfolio.get_or_create_portfolio', return_value=mock_portfolio): + with patch('finquest_api.routers.portfolio.ensure_snapshots_for_range'): + result = await get_snapshots(None, None, "daily", mock_user, mock_db) + + assert len(result.series) >= 1 + + @pytest.mark.anyio("asyncio") + async def test_get_snapshots_exception(self, mock_user, mock_db): + """Test snapshots retrieval with exception""" + with patch('finquest_api.routers.portfolio.get_or_create_portfolio', side_effect=Exception("Database error")): + with pytest.raises(Exception) as exc_info: + await get_snapshots(None, None, None, mock_user, mock_db) + + assert exc_info.value.status_code == 500 + + +class TestGenerateSnapshot: + """Tests for POST /portfolio/snapshots/generate endpoint""" + + @pytest.mark.anyio("asyncio") + async def test_generate_single_snapshot(self, mock_user, mock_db): + """Test generating a single snapshot""" + with patch('finquest_api.routers.portfolio.snapshot_user_portfolio') as mock_snapshot: + result = await generate_snapshot(None, None, mock_user, mock_db) + + assert result["status"] == "ok" + assert result["count"] == 1 + mock_snapshot.assert_called_once() + + @pytest.mark.anyio("asyncio") + async def test_generate_snapshot_range(self, mock_user, mock_db): + """Test generating snapshots for a date range""" + from_date = date.today() - timedelta(days=7) + to_date = date.today() + + with patch('finquest_api.routers.portfolio.snapshot_user_portfolio_range', return_value=8) as mock_snapshot_range: + result = await generate_snapshot(from_date, to_date, mock_user, mock_db) + + assert result["status"] == "ok" + assert result["count"] == 8 + mock_snapshot_range.assert_called_once() + + @pytest.mark.anyio("asyncio") + async def test_generate_snapshot_invalid_range(self, mock_user, mock_db): + """Test generating snapshots with invalid date range""" + from_date = date.today() + to_date = date.today() - timedelta(days=1) + + with pytest.raises(Exception) as exc_info: + await generate_snapshot(from_date, to_date, mock_user, mock_db) + + assert exc_info.value.status_code == 400 + assert "Start date must be before" in str(exc_info.value.detail) + + @pytest.mark.anyio("asyncio") + async def test_generate_snapshot_range_too_large(self, mock_user, mock_db): + """Test generating snapshots with range exceeding 365 days""" + from_date = date.today() - timedelta(days=400) + to_date = date.today() + + with pytest.raises(Exception) as exc_info: + await generate_snapshot(from_date, to_date, mock_user, mock_db) + + assert exc_info.value.status_code == 400 + assert "Date range cannot exceed 365 days" in str(exc_info.value.detail) + + @pytest.mark.anyio("asyncio") + async def test_generate_snapshot_exception(self, mock_user, mock_db): + """Test snapshot generation with exception""" + with patch('finquest_api.routers.portfolio.snapshot_user_portfolio', side_effect=Exception("Error")): + with pytest.raises(Exception) as exc_info: + await generate_snapshot(None, None, mock_user, mock_db) + + assert exc_info.value.status_code == 500 + diff --git a/app/services/api/tests/test_portfolio_router_missing.py b/app/services/api/tests/test_portfolio_router_missing.py new file mode 100644 index 0000000..28185b8 --- /dev/null +++ b/app/services/api/tests/test_portfolio_router_missing.py @@ -0,0 +1,190 @@ +""" +Tests for missing lines in portfolio router +""" +import pytest +from unittest.mock import Mock, MagicMock, patch +from uuid import uuid4 +from decimal import Decimal +from datetime import datetime, timedelta, timezone + +from finquest_api.routers.portfolio import add_position, get_snapshots +from finquest_api.db.models import User, Portfolio, PortfolioValuationSnapshot +from finquest_api.schemas import PostPositionRequest + + +@pytest.fixture +def mock_user(): + """Create a mock user""" + user = Mock(spec=User) + user.id = uuid4() + user.base_currency = "USD" + return user + + +@pytest.fixture +def mock_db(): + """Create a mock database session""" + db = MagicMock() + return db + + +class TestAddPositionMissingLines: + """Tests for missing lines in add_position""" + + @pytest.mark.anyio("asyncio") + async def test_add_position_zero_quantity(self, mock_user, mock_db): + """Test add_position with zero quantity (line 47)""" + # The endpoint checks quantity <= 0, but Pydantic prevents 0 + # We can test by creating a request with a very small positive value + # and then manually setting quantity to 0 after validation + request = PostPositionRequest( + symbol="AAPL", + quantity=Decimal("0.0001"), # Very small but positive + avgCost=Decimal("150.0") + ) + # Manually set to 0 to test endpoint validation + request.quantity = Decimal("0") + + with pytest.raises(Exception) as exc_info: + await add_position(request, mock_user, mock_db) + + # After fixing the router to re-raise HTTPException, it should be 400 + assert exc_info.value.status_code == 400 + assert "Quantity must be positive" in str(exc_info.value.detail) + + @pytest.mark.anyio("asyncio") + async def test_add_position_zero_cost(self, mock_user, mock_db): + """Test add_position with zero cost (line 52)""" + # Create request with valid quantity, then manually set cost to 0 + request = PostPositionRequest( + symbol="AAPL", + quantity=Decimal("10"), + avgCost=Decimal("0.0001") # Very small but positive + ) + # Manually set to 0 to test endpoint validation + request.avgCost = Decimal("0") + + with pytest.raises(Exception) as exc_info: + await add_position(request, mock_user, mock_db) + + # After fixing the router to re-raise HTTPException, it should be 400 + assert exc_info.value.status_code == 400 + assert "Average cost must be positive" in str(exc_info.value.detail) + + @pytest.mark.anyio("asyncio") + async def test_add_position_timezone_naive(self, mock_user, mock_db): + """Test add_position with timezone-naive datetime (line 76)""" + mock_portfolio = Mock(spec=Portfolio) + mock_portfolio.id = uuid4() + + # Create timezone-naive datetime + naive_time = datetime(2024, 1, 1, 12, 0, 0) + + request = PostPositionRequest( + symbol="AAPL", + quantity=10, + avgCost=150.0, + executedAt=naive_time + ) + + with patch('finquest_api.routers.portfolio.create_position_from_avg_cost', return_value=[uuid4()]): + with patch('finquest_api.routers.portfolio.get_or_create_portfolio', return_value=mock_portfolio): + with patch('finquest_api.routers.portfolio.recalculate_snapshots_after_transaction'): + result = await add_position(request, mock_user, mock_db) + + assert result.status == "ok" + + @pytest.mark.anyio("asyncio") + async def test_add_position_timezone_aware_conversion(self, mock_user, mock_db): + """Test add_position with timezone-aware datetime conversion (line 78)""" + mock_portfolio = Mock(spec=Portfolio) + mock_portfolio.id = uuid4() + + # Create timezone-aware datetime in different timezone + from datetime import timezone as tz + aware_time = datetime(2024, 1, 1, 12, 0, 0, tzinfo=tz(timedelta(hours=-5))) + + request = PostPositionRequest( + symbol="AAPL", + quantity=10, + avgCost=150.0, + executedAt=aware_time + ) + + with patch('finquest_api.routers.portfolio.create_position_from_avg_cost', return_value=[uuid4()]): + with patch('finquest_api.routers.portfolio.get_or_create_portfolio', return_value=mock_portfolio): + with patch('finquest_api.routers.portfolio.recalculate_snapshots_after_transaction') as mock_recalc: + result = await add_position(request, mock_user, mock_db) + + assert result.status == "ok" + mock_recalc.assert_called_once() + + @pytest.mark.anyio("asyncio") + async def test_add_position_value_error(self, mock_user, mock_db): + """Test add_position with ValueError (lines 103-104)""" + request = PostPositionRequest( + symbol="INVALID", + quantity=10, + avgCost=150.0 + ) + + with patch('finquest_api.routers.portfolio.create_position_from_avg_cost', side_effect=ValueError("Symbol not found")): + with pytest.raises(Exception) as exc_info: + await add_position(request, mock_user, mock_db) + + assert exc_info.value.status_code == 404 + + +class TestGetSnapshotsMissingLines: + """Tests for missing lines in get_snapshots""" + + @pytest.mark.anyio("asyncio") + async def test_get_snapshots_6hourly_granularity(self, mock_user, mock_db): + """Test getting snapshots with 6hourly granularity (lines 198-203)""" + mock_portfolio = Mock(spec=Portfolio) + mock_portfolio.id = uuid4() + + now = datetime.now(timezone.utc) + mock_snapshot1 = Mock(spec=PortfolioValuationSnapshot) + mock_snapshot1.as_of = now + mock_snapshot1.total_value = 1000.0 + + mock_snapshot2 = Mock(spec=PortfolioValuationSnapshot) + mock_snapshot2.as_of = now + timedelta(hours=7) + mock_snapshot2.total_value = 1100.0 + + mock_query = Mock() + mock_query.filter.return_value.order_by.return_value.all.return_value = [mock_snapshot1, mock_snapshot2] + mock_db.query.return_value = mock_query + + with patch('finquest_api.routers.portfolio.get_or_create_portfolio', return_value=mock_portfolio): + with patch('finquest_api.routers.portfolio.ensure_snapshots_for_range'): + result = await get_snapshots(None, None, "6hourly", mock_user, mock_db) + + assert len(result.series) >= 1 + + @pytest.mark.anyio("asyncio") + async def test_get_snapshots_weekly_granularity(self, mock_user, mock_db): + """Test getting snapshots with weekly granularity (lines 213-220)""" + mock_portfolio = Mock(spec=Portfolio) + mock_portfolio.id = uuid4() + + now = datetime.now(timezone.utc) + mock_snapshot1 = Mock(spec=PortfolioValuationSnapshot) + mock_snapshot1.as_of = now + mock_snapshot1.total_value = 1000.0 + + mock_snapshot2 = Mock(spec=PortfolioValuationSnapshot) + mock_snapshot2.as_of = now + timedelta(days=8) + mock_snapshot2.total_value = 1100.0 + + mock_query = Mock() + mock_query.filter.return_value.order_by.return_value.all.return_value = [mock_snapshot1, mock_snapshot2] + mock_db.query.return_value = mock_query + + with patch('finquest_api.routers.portfolio.get_or_create_portfolio', return_value=mock_portfolio): + with patch('finquest_api.routers.portfolio.ensure_snapshots_for_range'): + result = await get_snapshots(None, None, "weekly", mock_user, mock_db) + + assert len(result.series) >= 1 + diff --git a/app/services/api/tests/test_users_router.py b/app/services/api/tests/test_users_router.py new file mode 100644 index 0000000..54bb5e6 --- /dev/null +++ b/app/services/api/tests/test_users_router.py @@ -0,0 +1,204 @@ +""" +Tests for users router endpoints +""" +import pytest +from unittest.mock import Mock, MagicMock, patch, AsyncMock +from uuid import uuid4 + +from finquest_api.routers.users import ( + get_onboarding_status, + get_financial_profile, + update_financial_profile, + get_suggestions, +) +from finquest_api.db.models import User, OnboardingResponse, Suggestion +from finquest_api.schemas import UpdateProfileRequest, UserProfile + + +@pytest.fixture +def mock_user(): + """Create a mock user""" + user = Mock(spec=User) + user.id = uuid4() + return user + + +@pytest.fixture +def mock_db(): + """Create a mock database session""" + db = MagicMock() + return db + + +class TestGetOnboardingStatus: + """Tests for /onboarding-status endpoint""" + + @pytest.mark.anyio("asyncio") + async def test_onboarding_completed(self, mock_user, mock_db): + """Test when onboarding is completed""" + mock_response = Mock(spec=OnboardingResponse) + mock_db.query.return_value.filter.return_value.order_by.return_value.first.return_value = mock_response + + result = await get_onboarding_status(mock_user, mock_db) + + assert result["completed"] is True + + @pytest.mark.anyio("asyncio") + async def test_onboarding_not_completed(self, mock_user, mock_db): + """Test when onboarding is not completed""" + mock_db.query.return_value.filter.return_value.order_by.return_value.first.return_value = None + + result = await get_onboarding_status(mock_user, mock_db) + + assert result["completed"] is False + + @pytest.mark.anyio("asyncio") + async def test_onboarding_status_exception(self, mock_user, mock_db): + """Test exception handling""" + mock_db.query.side_effect = Exception("Database error") + + with pytest.raises(Exception) as exc_info: + await get_onboarding_status(mock_user, mock_db) + + assert exc_info.value.status_code == 500 + + +class TestGetFinancialProfile: + """Tests for /financial-profile endpoint""" + + @pytest.mark.anyio("asyncio") + async def test_get_financial_profile_with_data(self, mock_user, mock_db): + """Test getting financial profile with data""" + mock_response = Mock(spec=OnboardingResponse) + mock_response.answers = { + "risk_tolerance": "moderate", + "investment_goals": ["retirement"] + } + mock_db.query.return_value.filter.return_value.order_by.return_value.first.return_value = mock_response + + result = await get_financial_profile(mock_user, mock_db) + + assert isinstance(result, UserProfile) + + @pytest.mark.anyio("asyncio") + async def test_get_financial_profile_empty(self, mock_user, mock_db): + """Test getting financial profile when no data exists""" + mock_db.query.return_value.filter.return_value.order_by.return_value.first.return_value = None + + result = await get_financial_profile(mock_user, mock_db) + + assert isinstance(result, UserProfile) + + @pytest.mark.anyio("asyncio") + async def test_get_financial_profile_exception(self, mock_user, mock_db): + """Test exception handling""" + mock_db.query.side_effect = Exception("Database error") + + with pytest.raises(Exception) as exc_info: + await get_financial_profile(mock_user, mock_db) + + assert exc_info.value.status_code == 500 + + +class TestUpdateFinancialProfile: + """Tests for /financial-profile POST endpoint""" + + @pytest.mark.anyio("asyncio") + async def test_update_financial_profile_success(self, mock_user, mock_db): + """Test successful profile update""" + mock_background_tasks = Mock() + mock_suggestion_generator = AsyncMock() + + request = UpdateProfileRequest(risk_tolerance="moderate") + + with patch('finquest_api.routers.users.OnboardingResponse') as mock_response_class: + mock_response = Mock(spec=OnboardingResponse) + mock_response.id = uuid4() + mock_response_class.return_value = mock_response + + result = await update_financial_profile( + request, + mock_background_tasks, + mock_user, + mock_db, + mock_suggestion_generator + ) + + assert result["status"] == "ok" + mock_db.add.assert_called_once() + mock_db.commit.assert_called_once() + mock_background_tasks.add_task.assert_called_once() + + @pytest.mark.anyio("asyncio") + async def test_update_financial_profile_exception(self, mock_user, mock_db): + """Test exception handling""" + mock_background_tasks = Mock() + mock_suggestion_generator = AsyncMock() + mock_db.add.side_effect = Exception("Database error") + + request = UpdateProfileRequest(risk_tolerance="moderate") + + with pytest.raises(Exception) as exc_info: + await update_financial_profile( + request, + mock_background_tasks, + mock_user, + mock_db, + mock_suggestion_generator + ) + + assert exc_info.value.status_code == 500 + mock_db.rollback.assert_called_once() + + +class TestGetSuggestions: + """Tests for /suggestions endpoint""" + + @pytest.mark.anyio("asyncio") + async def test_get_suggestions_with_data(self, mock_user, mock_db): + """Test getting suggestions when they exist""" + mock_background_tasks = Mock() + mock_suggestion_generator = AsyncMock() + + mock_suggestion = Mock(spec=Suggestion) + mock_suggestion.id = uuid4() + mock_suggestion.reason = "Test reason" + mock_suggestion.confidence = 0.85 + mock_suggestion.module_id = uuid4() + mock_suggestion.status = "shown" + mock_suggestion.metadata_json = {} + mock_suggestion.created_at = None + + mock_db.query.return_value.filter.return_value.order_by.return_value.all.return_value = [mock_suggestion] + + result = await get_suggestions(mock_background_tasks, mock_user, mock_db, mock_suggestion_generator) + + assert len(result) == 1 + assert result[0].reason == "Test reason" + + @pytest.mark.anyio("asyncio") + async def test_get_suggestions_empty(self, mock_user, mock_db): + """Test getting suggestions when none exist""" + mock_background_tasks = Mock() + mock_suggestion_generator = AsyncMock() + + mock_db.query.return_value.filter.return_value.order_by.return_value.all.return_value = [] + + result = await get_suggestions(mock_background_tasks, mock_user, mock_db, mock_suggestion_generator) + + assert result == [] + mock_background_tasks.add_task.assert_called_once() + + @pytest.mark.anyio("asyncio") + async def test_get_suggestions_exception(self, mock_user, mock_db): + """Test exception handling""" + mock_background_tasks = Mock() + mock_suggestion_generator = AsyncMock() + mock_db.query.side_effect = Exception("Database error") + + with pytest.raises(Exception) as exc_info: + await get_suggestions(mock_background_tasks, mock_user, mock_db, mock_suggestion_generator) + + assert exc_info.value.status_code == 500 + + diff --git a/app/services/api/tests/test_users_router_extended.py b/app/services/api/tests/test_users_router_extended.py new file mode 100644 index 0000000..960e0ba --- /dev/null +++ b/app/services/api/tests/test_users_router_extended.py @@ -0,0 +1,73 @@ +""" +Extended tests for users router to cover missing lines +""" +import pytest +from unittest.mock import Mock, MagicMock, patch, AsyncMock +from uuid import uuid4 + +from finquest_api.routers.users import get_suggestion_generator, generate_suggestions_task +from finquest_api.db.models import User + + +class TestUsersDependencies: + """Tests for users router dependencies""" + + def test_get_suggestion_generator(self): + """Test get_suggestion_generator dependency (lines 19-22)""" + generator = get_suggestion_generator() + + assert generator is not None + # Verify it creates the expected components + assert hasattr(generator, 'generate_suggestions_for_user') + + @pytest.mark.anyio("asyncio") + async def test_generate_suggestions_task_success(self): + """Test generate_suggestions_task background task (lines 24-37)""" + mock_generator = AsyncMock() + mock_user_obj = Mock(spec=User) + mock_user_obj.id = uuid4() + + mock_db = MagicMock() + mock_db.query.return_value.filter.return_value.first.return_value = mock_user_obj + + with patch('finquest_api.routers.users.SessionLocal', return_value=mock_db): + with patch('finquest_api.routers.users.get_engine', return_value=Mock()): + await generate_suggestions_task(mock_generator, str(mock_user_obj.id)) + + mock_generator.generate_suggestions_for_user.assert_called_once() + mock_db.close.assert_called_once() + + @pytest.mark.anyio("asyncio") + async def test_generate_suggestions_task_no_user(self): + """Test generate_suggestions_task when user not found""" + mock_generator = AsyncMock() + mock_db = MagicMock() + mock_db.query.return_value.filter.return_value.first.return_value = None + + with patch('finquest_api.routers.users.SessionLocal', return_value=mock_db): + with patch('finquest_api.routers.users.get_engine', return_value=Mock()): + # Should not raise exception + await generate_suggestions_task(mock_generator, str(uuid4())) + + mock_generator.generate_suggestions_for_user.assert_not_called() + mock_db.close.assert_called_once() + + @pytest.mark.anyio("asyncio") + async def test_generate_suggestions_task_exception(self): + """Test generate_suggestions_task with exception""" + mock_generator = AsyncMock() + mock_generator.generate_suggestions_for_user.side_effect = Exception("Error") + mock_user_obj = Mock(spec=User) + mock_user_obj.id = uuid4() + + mock_db = MagicMock() + mock_db.query.return_value.filter.return_value.first.return_value = mock_user_obj + + with patch('finquest_api.routers.users.SessionLocal', return_value=mock_db): + with patch('finquest_api.routers.users.get_engine', return_value=Mock()): + # Should handle exception gracefully (prints error but doesn't raise) + await generate_suggestions_task(mock_generator, str(mock_user_obj.id)) + + mock_db.close.assert_called_once() + + diff --git a/app/services/api/uv.lock b/app/services/api/uv.lock index b2dd8cd..dabf47a 100644 --- a/app/services/api/uv.lock +++ b/app/services/api/uv.lock @@ -294,6 +294,234 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "coverage" +version = "7.10.7" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/51/26/d22c300112504f5f9a9fd2297ce33c35f3d353e4aeb987c8419453b2a7c2/coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239", size = 827704, upload-time = "2025-09-21T20:03:56.815Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6c/3a3f7a46888e69d18abe3ccc6fe4cb16cccb1e6a2f99698931dafca489e6/coverage-7.10.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fc04cc7a3db33664e0c2d10eb8990ff6b3536f6842c9590ae8da4c614b9ed05a", size = 217987, upload-time = "2025-09-21T20:00:57.218Z" }, + { url = "https://files.pythonhosted.org/packages/03/94/952d30f180b1a916c11a56f5c22d3535e943aa22430e9e3322447e520e1c/coverage-7.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e201e015644e207139f7e2351980feb7040e6f4b2c2978892f3e3789d1c125e5", size = 218388, upload-time = "2025-09-21T20:01:00.081Z" }, + { url = "https://files.pythonhosted.org/packages/50/2b/9e0cf8ded1e114bcd8b2fd42792b57f1c4e9e4ea1824cde2af93a67305be/coverage-7.10.7-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:240af60539987ced2c399809bd34f7c78e8abe0736af91c3d7d0e795df633d17", size = 245148, upload-time = "2025-09-21T20:01:01.768Z" }, + { url = "https://files.pythonhosted.org/packages/19/20/d0384ac06a6f908783d9b6aa6135e41b093971499ec488e47279f5b846e6/coverage-7.10.7-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8421e088bc051361b01c4b3a50fd39a4b9133079a2229978d9d30511fd05231b", size = 246958, upload-time = "2025-09-21T20:01:03.355Z" }, + { url = "https://files.pythonhosted.org/packages/60/83/5c283cff3d41285f8eab897651585db908a909c572bdc014bcfaf8a8b6ae/coverage-7.10.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6be8ed3039ae7f7ac5ce058c308484787c86e8437e72b30bf5e88b8ea10f3c87", size = 248819, upload-time = "2025-09-21T20:01:04.968Z" }, + { url = "https://files.pythonhosted.org/packages/60/22/02eb98fdc5ff79f423e990d877693e5310ae1eab6cb20ae0b0b9ac45b23b/coverage-7.10.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e28299d9f2e889e6d51b1f043f58d5f997c373cc12e6403b90df95b8b047c13e", size = 245754, upload-time = "2025-09-21T20:01:06.321Z" }, + { url = "https://files.pythonhosted.org/packages/b4/bc/25c83bcf3ad141b32cd7dc45485ef3c01a776ca3aa8ef0a93e77e8b5bc43/coverage-7.10.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c4e16bd7761c5e454f4efd36f345286d6f7c5fa111623c355691e2755cae3b9e", size = 246860, upload-time = "2025-09-21T20:01:07.605Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b7/95574702888b58c0928a6e982038c596f9c34d52c5e5107f1eef729399b5/coverage-7.10.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b1c81d0e5e160651879755c9c675b974276f135558cf4ba79fee7b8413a515df", size = 244877, upload-time = "2025-09-21T20:01:08.829Z" }, + { url = "https://files.pythonhosted.org/packages/47/b6/40095c185f235e085df0e0b158f6bd68cc6e1d80ba6c7721dc81d97ec318/coverage-7.10.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:606cc265adc9aaedcc84f1f064f0e8736bc45814f15a357e30fca7ecc01504e0", size = 245108, upload-time = "2025-09-21T20:01:10.527Z" }, + { url = "https://files.pythonhosted.org/packages/c8/50/4aea0556da7a4b93ec9168420d170b55e2eb50ae21b25062513d020c6861/coverage-7.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:10b24412692df990dbc34f8fb1b6b13d236ace9dfdd68df5b28c2e39cafbba13", size = 245752, upload-time = "2025-09-21T20:01:11.857Z" }, + { url = "https://files.pythonhosted.org/packages/6a/28/ea1a84a60828177ae3b100cb6723838523369a44ec5742313ed7db3da160/coverage-7.10.7-cp310-cp310-win32.whl", hash = "sha256:b51dcd060f18c19290d9b8a9dd1e0181538df2ce0717f562fff6cf74d9fc0b5b", size = 220497, upload-time = "2025-09-21T20:01:13.459Z" }, + { url = "https://files.pythonhosted.org/packages/fc/1a/a81d46bbeb3c3fd97b9602ebaa411e076219a150489bcc2c025f151bd52d/coverage-7.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:3a622ac801b17198020f09af3eaf45666b344a0d69fc2a6ffe2ea83aeef1d807", size = 221392, upload-time = "2025-09-21T20:01:14.722Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5d/c1a17867b0456f2e9ce2d8d4708a4c3a089947d0bec9c66cdf60c9e7739f/coverage-7.10.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a609f9c93113be646f44c2a0256d6ea375ad047005d7f57a5c15f614dc1b2f59", size = 218102, upload-time = "2025-09-21T20:01:16.089Z" }, + { url = "https://files.pythonhosted.org/packages/54/f0/514dcf4b4e3698b9a9077f084429681bf3aad2b4a72578f89d7f643eb506/coverage-7.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:65646bb0359386e07639c367a22cf9b5bf6304e8630b565d0626e2bdf329227a", size = 218505, upload-time = "2025-09-21T20:01:17.788Z" }, + { url = "https://files.pythonhosted.org/packages/20/f6/9626b81d17e2a4b25c63ac1b425ff307ecdeef03d67c9a147673ae40dc36/coverage-7.10.7-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5f33166f0dfcce728191f520bd2692914ec70fac2713f6bf3ce59c3deacb4699", size = 248898, upload-time = "2025-09-21T20:01:19.488Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ef/bd8e719c2f7417ba03239052e099b76ea1130ac0cbb183ee1fcaa58aaff3/coverage-7.10.7-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f5e3f9e455bb17831876048355dca0f758b6df22f49258cb5a91da23ef437d", size = 250831, upload-time = "2025-09-21T20:01:20.817Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b6/bf054de41ec948b151ae2b79a55c107f5760979538f5fb80c195f2517718/coverage-7.10.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da86b6d62a496e908ac2898243920c7992499c1712ff7c2b6d837cc69d9467e", size = 252937, upload-time = "2025-09-21T20:01:22.171Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e5/3860756aa6f9318227443c6ce4ed7bf9e70bb7f1447a0353f45ac5c7974b/coverage-7.10.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b8b09c1fad947c84bbbc95eca841350fad9cbfa5a2d7ca88ac9f8d836c92e23", size = 249021, upload-time = "2025-09-21T20:01:23.907Z" }, + { url = "https://files.pythonhosted.org/packages/26/0f/bd08bd042854f7fd07b45808927ebcce99a7ed0f2f412d11629883517ac2/coverage-7.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4376538f36b533b46f8971d3a3e63464f2c7905c9800db97361c43a2b14792ab", size = 250626, upload-time = "2025-09-21T20:01:25.721Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a7/4777b14de4abcc2e80c6b1d430f5d51eb18ed1d75fca56cbce5f2db9b36e/coverage-7.10.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:121da30abb574f6ce6ae09840dae322bef734480ceafe410117627aa54f76d82", size = 248682, upload-time = "2025-09-21T20:01:27.105Z" }, + { url = "https://files.pythonhosted.org/packages/34/72/17d082b00b53cd45679bad682fac058b87f011fd8b9fe31d77f5f8d3a4e4/coverage-7.10.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:88127d40df529336a9836870436fc2751c339fbaed3a836d42c93f3e4bd1d0a2", size = 248402, upload-time = "2025-09-21T20:01:28.629Z" }, + { url = "https://files.pythonhosted.org/packages/81/7a/92367572eb5bdd6a84bfa278cc7e97db192f9f45b28c94a9ca1a921c3577/coverage-7.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ba58bbcd1b72f136080c0bccc2400d66cc6115f3f906c499013d065ac33a4b61", size = 249320, upload-time = "2025-09-21T20:01:30.004Z" }, + { url = "https://files.pythonhosted.org/packages/2f/88/a23cc185f6a805dfc4fdf14a94016835eeb85e22ac3a0e66d5e89acd6462/coverage-7.10.7-cp311-cp311-win32.whl", hash = "sha256:972b9e3a4094b053a4e46832b4bc829fc8a8d347160eb39d03f1690316a99c14", size = 220536, upload-time = "2025-09-21T20:01:32.184Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ef/0b510a399dfca17cec7bc2f05ad8bd78cf55f15c8bc9a73ab20c5c913c2e/coverage-7.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:a7b55a944a7f43892e28ad4bc0561dfd5f0d73e605d1aa5c3c976b52aea121d2", size = 221425, upload-time = "2025-09-21T20:01:33.557Z" }, + { url = "https://files.pythonhosted.org/packages/51/7f/023657f301a276e4ba1850f82749bc136f5a7e8768060c2e5d9744a22951/coverage-7.10.7-cp311-cp311-win_arm64.whl", hash = "sha256:736f227fb490f03c6488f9b6d45855f8e0fd749c007f9303ad30efab0e73c05a", size = 220103, upload-time = "2025-09-21T20:01:34.929Z" }, + { url = "https://files.pythonhosted.org/packages/13/e4/eb12450f71b542a53972d19117ea5a5cea1cab3ac9e31b0b5d498df1bd5a/coverage-7.10.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7bb3b9ddb87ef7725056572368040c32775036472d5a033679d1fa6c8dc08417", size = 218290, upload-time = "2025-09-21T20:01:36.455Z" }, + { url = "https://files.pythonhosted.org/packages/37/66/593f9be12fc19fb36711f19a5371af79a718537204d16ea1d36f16bd78d2/coverage-7.10.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:18afb24843cbc175687225cab1138c95d262337f5473512010e46831aa0c2973", size = 218515, upload-time = "2025-09-21T20:01:37.982Z" }, + { url = "https://files.pythonhosted.org/packages/66/80/4c49f7ae09cafdacc73fbc30949ffe77359635c168f4e9ff33c9ebb07838/coverage-7.10.7-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:399a0b6347bcd3822be369392932884b8216d0944049ae22925631a9b3d4ba4c", size = 250020, upload-time = "2025-09-21T20:01:39.617Z" }, + { url = "https://files.pythonhosted.org/packages/a6/90/a64aaacab3b37a17aaedd83e8000142561a29eb262cede42d94a67f7556b/coverage-7.10.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314f2c326ded3f4b09be11bc282eb2fc861184bc95748ae67b360ac962770be7", size = 252769, upload-time = "2025-09-21T20:01:41.341Z" }, + { url = "https://files.pythonhosted.org/packages/98/2e/2dda59afd6103b342e096f246ebc5f87a3363b5412609946c120f4e7750d/coverage-7.10.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c41e71c9cfb854789dee6fc51e46743a6d138b1803fab6cb860af43265b42ea6", size = 253901, upload-time = "2025-09-21T20:01:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/53/dc/8d8119c9051d50f3119bb4a75f29f1e4a6ab9415cd1fa8bf22fcc3fb3b5f/coverage-7.10.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc01f57ca26269c2c706e838f6422e2a8788e41b3e3c65e2f41148212e57cd59", size = 250413, upload-time = "2025-09-21T20:01:44.469Z" }, + { url = "https://files.pythonhosted.org/packages/98/b3/edaff9c5d79ee4d4b6d3fe046f2b1d799850425695b789d491a64225d493/coverage-7.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a6442c59a8ac8b85812ce33bc4d05bde3fb22321fa8294e2a5b487c3505f611b", size = 251820, upload-time = "2025-09-21T20:01:45.915Z" }, + { url = "https://files.pythonhosted.org/packages/11/25/9a0728564bb05863f7e513e5a594fe5ffef091b325437f5430e8cfb0d530/coverage-7.10.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:78a384e49f46b80fb4c901d52d92abe098e78768ed829c673fbb53c498bef73a", size = 249941, upload-time = "2025-09-21T20:01:47.296Z" }, + { url = "https://files.pythonhosted.org/packages/e0/fd/ca2650443bfbef5b0e74373aac4df67b08180d2f184b482c41499668e258/coverage-7.10.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5e1e9802121405ede4b0133aa4340ad8186a1d2526de5b7c3eca519db7bb89fb", size = 249519, upload-time = "2025-09-21T20:01:48.73Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/f692f125fb4299b6f963b0745124998ebb8e73ecdfce4ceceb06a8c6bec5/coverage-7.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d41213ea25a86f69efd1575073d34ea11aabe075604ddf3d148ecfec9e1e96a1", size = 251375, upload-time = "2025-09-21T20:01:50.529Z" }, + { url = "https://files.pythonhosted.org/packages/5e/75/61b9bbd6c7d24d896bfeec57acba78e0f8deac68e6baf2d4804f7aae1f88/coverage-7.10.7-cp312-cp312-win32.whl", hash = "sha256:77eb4c747061a6af8d0f7bdb31f1e108d172762ef579166ec84542f711d90256", size = 220699, upload-time = "2025-09-21T20:01:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f3/3bf7905288b45b075918d372498f1cf845b5b579b723c8fd17168018d5f5/coverage-7.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:f51328ffe987aecf6d09f3cd9d979face89a617eacdaea43e7b3080777f647ba", size = 221512, upload-time = "2025-09-21T20:01:53.481Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/3e32dbe933979d05cf2dac5e697c8599cfe038aaf51223ab901e208d5a62/coverage-7.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:bda5e34f8a75721c96085903c6f2197dc398c20ffd98df33f866a9c8fd95f4bf", size = 220147, upload-time = "2025-09-21T20:01:55.2Z" }, + { url = "https://files.pythonhosted.org/packages/9a/94/b765c1abcb613d103b64fcf10395f54d69b0ef8be6a0dd9c524384892cc7/coverage-7.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:981a651f543f2854abd3b5fcb3263aac581b18209be49863ba575de6edf4c14d", size = 218320, upload-time = "2025-09-21T20:01:56.629Z" }, + { url = "https://files.pythonhosted.org/packages/72/4f/732fff31c119bb73b35236dd333030f32c4bfe909f445b423e6c7594f9a2/coverage-7.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73ab1601f84dc804f7812dc297e93cd99381162da39c47040a827d4e8dafe63b", size = 218575, upload-time = "2025-09-21T20:01:58.203Z" }, + { url = "https://files.pythonhosted.org/packages/87/02/ae7e0af4b674be47566707777db1aa375474f02a1d64b9323e5813a6cdd5/coverage-7.10.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8b6f03672aa6734e700bbcd65ff050fd19cddfec4b031cc8cf1c6967de5a68e", size = 249568, upload-time = "2025-09-21T20:01:59.748Z" }, + { url = "https://files.pythonhosted.org/packages/a2/77/8c6d22bf61921a59bce5471c2f1f7ac30cd4ac50aadde72b8c48d5727902/coverage-7.10.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10b6ba00ab1132a0ce4428ff68cf50a25efd6840a42cdf4239c9b99aad83be8b", size = 252174, upload-time = "2025-09-21T20:02:01.192Z" }, + { url = "https://files.pythonhosted.org/packages/b1/20/b6ea4f69bbb52dac0aebd62157ba6a9dddbfe664f5af8122dac296c3ee15/coverage-7.10.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c79124f70465a150e89340de5963f936ee97097d2ef76c869708c4248c63ca49", size = 253447, upload-time = "2025-09-21T20:02:02.701Z" }, + { url = "https://files.pythonhosted.org/packages/f9/28/4831523ba483a7f90f7b259d2018fef02cb4d5b90bc7c1505d6e5a84883c/coverage-7.10.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:69212fbccdbd5b0e39eac4067e20a4a5256609e209547d86f740d68ad4f04911", size = 249779, upload-time = "2025-09-21T20:02:04.185Z" }, + { url = "https://files.pythonhosted.org/packages/a7/9f/4331142bc98c10ca6436d2d620c3e165f31e6c58d43479985afce6f3191c/coverage-7.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ea7c6c9d0d286d04ed3541747e6597cbe4971f22648b68248f7ddcd329207f0", size = 251604, upload-time = "2025-09-21T20:02:06.034Z" }, + { url = "https://files.pythonhosted.org/packages/ce/60/bda83b96602036b77ecf34e6393a3836365481b69f7ed7079ab85048202b/coverage-7.10.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b9be91986841a75042b3e3243d0b3cb0b2434252b977baaf0cd56e960fe1e46f", size = 249497, upload-time = "2025-09-21T20:02:07.619Z" }, + { url = "https://files.pythonhosted.org/packages/5f/af/152633ff35b2af63977edd835d8e6430f0caef27d171edf2fc76c270ef31/coverage-7.10.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b281d5eca50189325cfe1f365fafade89b14b4a78d9b40b05ddd1fc7d2a10a9c", size = 249350, upload-time = "2025-09-21T20:02:10.34Z" }, + { url = "https://files.pythonhosted.org/packages/9d/71/d92105d122bd21cebba877228990e1646d862e34a98bb3374d3fece5a794/coverage-7.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:99e4aa63097ab1118e75a848a28e40d68b08a5e19ce587891ab7fd04475e780f", size = 251111, upload-time = "2025-09-21T20:02:12.122Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9e/9fdb08f4bf476c912f0c3ca292e019aab6712c93c9344a1653986c3fd305/coverage-7.10.7-cp313-cp313-win32.whl", hash = "sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698", size = 220746, upload-time = "2025-09-21T20:02:13.919Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b1/a75fd25df44eab52d1931e89980d1ada46824c7a3210be0d3c88a44aaa99/coverage-7.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:cac0fdca17b036af3881a9d2729a850b76553f3f716ccb0360ad4dbc06b3b843", size = 221541, upload-time = "2025-09-21T20:02:15.57Z" }, + { url = "https://files.pythonhosted.org/packages/14/3a/d720d7c989562a6e9a14b2c9f5f2876bdb38e9367126d118495b89c99c37/coverage-7.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f236edf6e2f9ae8fcd1332da4e791c1b6ba0dc16a2dc94590ceccb482e546", size = 220170, upload-time = "2025-09-21T20:02:17.395Z" }, + { url = "https://files.pythonhosted.org/packages/bb/22/e04514bf2a735d8b0add31d2b4ab636fc02370730787c576bb995390d2d5/coverage-7.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0ec07fd264d0745ee396b666d47cef20875f4ff2375d7c4f58235886cc1ef0c", size = 219029, upload-time = "2025-09-21T20:02:18.936Z" }, + { url = "https://files.pythonhosted.org/packages/11/0b/91128e099035ece15da3445d9015e4b4153a6059403452d324cbb0a575fa/coverage-7.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd5e856ebb7bfb7672b0086846db5afb4567a7b9714b8a0ebafd211ec7ce6a15", size = 219259, upload-time = "2025-09-21T20:02:20.44Z" }, + { url = "https://files.pythonhosted.org/packages/8b/51/66420081e72801536a091a0c8f8c1f88a5c4bf7b9b1bdc6222c7afe6dc9b/coverage-7.10.7-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f57b2a3c8353d3e04acf75b3fed57ba41f5c0646bbf1d10c7c282291c97936b4", size = 260592, upload-time = "2025-09-21T20:02:22.313Z" }, + { url = "https://files.pythonhosted.org/packages/5d/22/9b8d458c2881b22df3db5bb3e7369e63d527d986decb6c11a591ba2364f7/coverage-7.10.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ef2319dd15a0b009667301a3f84452a4dc6fddfd06b0c5c53ea472d3989fbf0", size = 262768, upload-time = "2025-09-21T20:02:24.287Z" }, + { url = "https://files.pythonhosted.org/packages/f7/08/16bee2c433e60913c610ea200b276e8eeef084b0d200bdcff69920bd5828/coverage-7.10.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83082a57783239717ceb0ad584de3c69cf581b2a95ed6bf81ea66034f00401c0", size = 264995, upload-time = "2025-09-21T20:02:26.133Z" }, + { url = "https://files.pythonhosted.org/packages/20/9d/e53eb9771d154859b084b90201e5221bca7674ba449a17c101a5031d4054/coverage-7.10.7-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50aa94fb1fb9a397eaa19c0d5ec15a5edd03a47bf1a3a6111a16b36e190cff65", size = 259546, upload-time = "2025-09-21T20:02:27.716Z" }, + { url = "https://files.pythonhosted.org/packages/ad/b0/69bc7050f8d4e56a89fb550a1577d5d0d1db2278106f6f626464067b3817/coverage-7.10.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2120043f147bebb41c85b97ac45dd173595ff14f2a584f2963891cbcc3091541", size = 262544, upload-time = "2025-09-21T20:02:29.216Z" }, + { url = "https://files.pythonhosted.org/packages/ef/4b/2514b060dbd1bc0aaf23b852c14bb5818f244c664cb16517feff6bb3a5ab/coverage-7.10.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2fafd773231dd0378fdba66d339f84904a8e57a262f583530f4f156ab83863e6", size = 260308, upload-time = "2025-09-21T20:02:31.226Z" }, + { url = "https://files.pythonhosted.org/packages/54/78/7ba2175007c246d75e496f64c06e94122bdb914790a1285d627a918bd271/coverage-7.10.7-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:0b944ee8459f515f28b851728ad224fa2d068f1513ef6b7ff1efafeb2185f999", size = 258920, upload-time = "2025-09-21T20:02:32.823Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b3/fac9f7abbc841409b9a410309d73bfa6cfb2e51c3fada738cb607ce174f8/coverage-7.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4b583b97ab2e3efe1b3e75248a9b333bd3f8b0b1b8e5b45578e05e5850dfb2c2", size = 261434, upload-time = "2025-09-21T20:02:34.86Z" }, + { url = "https://files.pythonhosted.org/packages/ee/51/a03bec00d37faaa891b3ff7387192cef20f01604e5283a5fabc95346befa/coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a", size = 221403, upload-time = "2025-09-21T20:02:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/53/22/3cf25d614e64bf6d8e59c7c669b20d6d940bb337bdee5900b9ca41c820bb/coverage-7.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb", size = 222469, upload-time = "2025-09-21T20:02:39.011Z" }, + { url = "https://files.pythonhosted.org/packages/49/a1/00164f6d30d8a01c3c9c48418a7a5be394de5349b421b9ee019f380df2a0/coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb", size = 220731, upload-time = "2025-09-21T20:02:40.939Z" }, + { url = "https://files.pythonhosted.org/packages/23/9c/5844ab4ca6a4dd97a1850e030a15ec7d292b5c5cb93082979225126e35dd/coverage-7.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b06f260b16ead11643a5a9f955bd4b5fd76c1a4c6796aeade8520095b75de520", size = 218302, upload-time = "2025-09-21T20:02:42.527Z" }, + { url = "https://files.pythonhosted.org/packages/f0/89/673f6514b0961d1f0e20ddc242e9342f6da21eaba3489901b565c0689f34/coverage-7.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:212f8f2e0612778f09c55dd4872cb1f64a1f2b074393d139278ce902064d5b32", size = 218578, upload-time = "2025-09-21T20:02:44.468Z" }, + { url = "https://files.pythonhosted.org/packages/05/e8/261cae479e85232828fb17ad536765c88dd818c8470aca690b0ac6feeaa3/coverage-7.10.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3445258bcded7d4aa630ab8296dea4d3f15a255588dd535f980c193ab6b95f3f", size = 249629, upload-time = "2025-09-21T20:02:46.503Z" }, + { url = "https://files.pythonhosted.org/packages/82/62/14ed6546d0207e6eda876434e3e8475a3e9adbe32110ce896c9e0c06bb9a/coverage-7.10.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb45474711ba385c46a0bfe696c695a929ae69ac636cda8f532be9e8c93d720a", size = 252162, upload-time = "2025-09-21T20:02:48.689Z" }, + { url = "https://files.pythonhosted.org/packages/ff/49/07f00db9ac6478e4358165a08fb41b469a1b053212e8a00cb02f0d27a05f/coverage-7.10.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:813922f35bd800dca9994c5971883cbc0d291128a5de6b167c7aa697fcf59360", size = 253517, upload-time = "2025-09-21T20:02:50.31Z" }, + { url = "https://files.pythonhosted.org/packages/a2/59/c5201c62dbf165dfbc91460f6dbbaa85a8b82cfa6131ac45d6c1bfb52deb/coverage-7.10.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c1b03552081b2a4423091d6fb3787265b8f86af404cff98d1b5342713bdd69", size = 249632, upload-time = "2025-09-21T20:02:51.971Z" }, + { url = "https://files.pythonhosted.org/packages/07/ae/5920097195291a51fb00b3a70b9bbd2edbfe3c84876a1762bd1ef1565ebc/coverage-7.10.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc87dd1b6eaf0b848eebb1c86469b9f72a1891cb42ac7adcfbce75eadb13dd14", size = 251520, upload-time = "2025-09-21T20:02:53.858Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3c/a815dde77a2981f5743a60b63df31cb322c944843e57dbd579326625a413/coverage-7.10.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:39508ffda4f343c35f3236fe8d1a6634a51f4581226a1262769d7f970e73bffe", size = 249455, upload-time = "2025-09-21T20:02:55.807Z" }, + { url = "https://files.pythonhosted.org/packages/aa/99/f5cdd8421ea656abefb6c0ce92556709db2265c41e8f9fc6c8ae0f7824c9/coverage-7.10.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:925a1edf3d810537c5a3abe78ec5530160c5f9a26b1f4270b40e62cc79304a1e", size = 249287, upload-time = "2025-09-21T20:02:57.784Z" }, + { url = "https://files.pythonhosted.org/packages/c3/7a/e9a2da6a1fc5d007dd51fca083a663ab930a8c4d149c087732a5dbaa0029/coverage-7.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2c8b9a0636f94c43cd3576811e05b89aa9bc2d0a85137affc544ae5cb0e4bfbd", size = 250946, upload-time = "2025-09-21T20:02:59.431Z" }, + { url = "https://files.pythonhosted.org/packages/ef/5b/0b5799aa30380a949005a353715095d6d1da81927d6dbed5def2200a4e25/coverage-7.10.7-cp314-cp314-win32.whl", hash = "sha256:b7b8288eb7cdd268b0304632da8cb0bb93fadcfec2fe5712f7b9cc8f4d487be2", size = 221009, upload-time = "2025-09-21T20:03:01.324Z" }, + { url = "https://files.pythonhosted.org/packages/da/b0/e802fbb6eb746de006490abc9bb554b708918b6774b722bb3a0e6aa1b7de/coverage-7.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:1ca6db7c8807fb9e755d0379ccc39017ce0a84dcd26d14b5a03b78563776f681", size = 221804, upload-time = "2025-09-21T20:03:03.4Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e8/71d0c8e374e31f39e3389bb0bd19e527d46f00ea8571ec7ec8fd261d8b44/coverage-7.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:097c1591f5af4496226d5783d036bf6fd6cd0cbc132e071b33861de756efb880", size = 220384, upload-time = "2025-09-21T20:03:05.111Z" }, + { url = "https://files.pythonhosted.org/packages/62/09/9a5608d319fa3eba7a2019addeacb8c746fb50872b57a724c9f79f146969/coverage-7.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a62c6ef0d50e6de320c270ff91d9dd0a05e7250cac2a800b7784bae474506e63", size = 219047, upload-time = "2025-09-21T20:03:06.795Z" }, + { url = "https://files.pythonhosted.org/packages/f5/6f/f58d46f33db9f2e3647b2d0764704548c184e6f5e014bef528b7f979ef84/coverage-7.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9fa6e4dd51fe15d8738708a973470f67a855ca50002294852e9571cdbd9433f2", size = 219266, upload-time = "2025-09-21T20:03:08.495Z" }, + { url = "https://files.pythonhosted.org/packages/74/5c/183ffc817ba68e0b443b8c934c8795553eb0c14573813415bd59941ee165/coverage-7.10.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8fb190658865565c549b6b4706856d6a7b09302c797eb2cf8e7fe9dabb043f0d", size = 260767, upload-time = "2025-09-21T20:03:10.172Z" }, + { url = "https://files.pythonhosted.org/packages/0f/48/71a8abe9c1ad7e97548835e3cc1adbf361e743e9d60310c5f75c9e7bf847/coverage-7.10.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:affef7c76a9ef259187ef31599a9260330e0335a3011732c4b9effa01e1cd6e0", size = 262931, upload-time = "2025-09-21T20:03:11.861Z" }, + { url = "https://files.pythonhosted.org/packages/84/fd/193a8fb132acfc0a901f72020e54be5e48021e1575bb327d8ee1097a28fd/coverage-7.10.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e16e07d85ca0cf8bafe5f5d23a0b850064e8e945d5677492b06bbe6f09cc699", size = 265186, upload-time = "2025-09-21T20:03:13.539Z" }, + { url = "https://files.pythonhosted.org/packages/b1/8f/74ecc30607dd95ad50e3034221113ccb1c6d4e8085cc761134782995daae/coverage-7.10.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03ffc58aacdf65d2a82bbeb1ffe4d01ead4017a21bfd0454983b88ca73af94b9", size = 259470, upload-time = "2025-09-21T20:03:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/0f/55/79ff53a769f20d71b07023ea115c9167c0bb56f281320520cf64c5298a96/coverage-7.10.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1b4fd784344d4e52647fd7857b2af5b3fbe6c239b0b5fa63e94eb67320770e0f", size = 262626, upload-time = "2025-09-21T20:03:17.673Z" }, + { url = "https://files.pythonhosted.org/packages/88/e2/dac66c140009b61ac3fc13af673a574b00c16efdf04f9b5c740703e953c0/coverage-7.10.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ebbaddb2c19b71912c6f2518e791aa8b9f054985a0769bdb3a53ebbc765c6a1", size = 260386, upload-time = "2025-09-21T20:03:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f1/f48f645e3f33bb9ca8a496bc4a9671b52f2f353146233ebd7c1df6160440/coverage-7.10.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a2d9a3b260cc1d1dbdb1c582e63ddcf5363426a1a68faa0f5da28d8ee3c722a0", size = 258852, upload-time = "2025-09-21T20:03:21.007Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3b/8442618972c51a7affeead957995cfa8323c0c9bcf8fa5a027421f720ff4/coverage-7.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a3cc8638b2480865eaa3926d192e64ce6c51e3d29c849e09d5b4ad95efae5399", size = 261534, upload-time = "2025-09-21T20:03:23.12Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dc/101f3fa3a45146db0cb03f5b4376e24c0aac818309da23e2de0c75295a91/coverage-7.10.7-cp314-cp314t-win32.whl", hash = "sha256:67f8c5cbcd3deb7a60b3345dffc89a961a484ed0af1f6f73de91705cc6e31235", size = 221784, upload-time = "2025-09-21T20:03:24.769Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a1/74c51803fc70a8a40d7346660379e144be772bab4ac7bb6e6b905152345c/coverage-7.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e1ed71194ef6dea7ed2d5cb5f7243d4bcd334bfb63e59878519be558078f848d", size = 222905, upload-time = "2025-09-21T20:03:26.93Z" }, + { url = "https://files.pythonhosted.org/packages/12/65/f116a6d2127df30bcafbceef0302d8a64ba87488bf6f73a6d8eebf060873/coverage-7.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:7fe650342addd8524ca63d77b2362b02345e5f1a093266787d210c70a50b471a", size = 220922, upload-time = "2025-09-21T20:03:28.672Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ad/d1c25053764b4c42eb294aae92ab617d2e4f803397f9c7c8295caa77a260/coverage-7.10.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fff7b9c3f19957020cac546c70025331113d2e61537f6e2441bc7657913de7d3", size = 217978, upload-time = "2025-09-21T20:03:30.362Z" }, + { url = "https://files.pythonhosted.org/packages/52/2f/b9f9daa39b80ece0b9548bbb723381e29bc664822d9a12c2135f8922c22b/coverage-7.10.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bc91b314cef27742da486d6839b677b3f2793dfe52b51bbbb7cf736d5c29281c", size = 218370, upload-time = "2025-09-21T20:03:32.147Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6e/30d006c3b469e58449650642383dddf1c8fb63d44fdf92994bfd46570695/coverage-7.10.7-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:567f5c155eda8df1d3d439d40a45a6a5f029b429b06648235f1e7e51b522b396", size = 244802, upload-time = "2025-09-21T20:03:33.919Z" }, + { url = "https://files.pythonhosted.org/packages/b0/49/8a070782ce7e6b94ff6a0b6d7c65ba6bc3091d92a92cef4cd4eb0767965c/coverage-7.10.7-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2af88deffcc8a4d5974cf2d502251bc3b2db8461f0b66d80a449c33757aa9f40", size = 246625, upload-time = "2025-09-21T20:03:36.09Z" }, + { url = "https://files.pythonhosted.org/packages/6a/92/1c1c5a9e8677ce56d42b97bdaca337b2d4d9ebe703d8c174ede52dbabd5f/coverage-7.10.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7315339eae3b24c2d2fa1ed7d7a38654cba34a13ef19fbcb9425da46d3dc594", size = 248399, upload-time = "2025-09-21T20:03:38.342Z" }, + { url = "https://files.pythonhosted.org/packages/c0/54/b140edee7257e815de7426d5d9846b58505dffc29795fff2dfb7f8a1c5a0/coverage-7.10.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:912e6ebc7a6e4adfdbb1aec371ad04c68854cd3bf3608b3514e7ff9062931d8a", size = 245142, upload-time = "2025-09-21T20:03:40.591Z" }, + { url = "https://files.pythonhosted.org/packages/e4/9e/6d6b8295940b118e8b7083b29226c71f6154f7ff41e9ca431f03de2eac0d/coverage-7.10.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f49a05acd3dfe1ce9715b657e28d138578bc40126760efb962322c56e9ca344b", size = 246284, upload-time = "2025-09-21T20:03:42.355Z" }, + { url = "https://files.pythonhosted.org/packages/db/e5/5e957ca747d43dbe4d9714358375c7546cb3cb533007b6813fc20fce37ad/coverage-7.10.7-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:cce2109b6219f22ece99db7644b9622f54a4e915dad65660ec435e89a3ea7cc3", size = 244353, upload-time = "2025-09-21T20:03:44.218Z" }, + { url = "https://files.pythonhosted.org/packages/9a/45/540fc5cc92536a1b783b7ef99450bd55a4b3af234aae35a18a339973ce30/coverage-7.10.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:f3c887f96407cea3916294046fc7dab611c2552beadbed4ea901cbc6a40cc7a0", size = 244430, upload-time = "2025-09-21T20:03:46.065Z" }, + { url = "https://files.pythonhosted.org/packages/75/0b/8287b2e5b38c8fe15d7e3398849bb58d382aedc0864ea0fa1820e8630491/coverage-7.10.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:635adb9a4507c9fd2ed65f39693fa31c9a3ee3a8e6dc64df033e8fdf52a7003f", size = 245311, upload-time = "2025-09-21T20:03:48.19Z" }, + { url = "https://files.pythonhosted.org/packages/0c/1d/29724999984740f0c86d03e6420b942439bf5bd7f54d4382cae386a9d1e9/coverage-7.10.7-cp39-cp39-win32.whl", hash = "sha256:5a02d5a850e2979b0a014c412573953995174743a3f7fa4ea5a6e9a3c5617431", size = 220500, upload-time = "2025-09-21T20:03:50.024Z" }, + { url = "https://files.pythonhosted.org/packages/43/11/4b1e6b129943f905ca54c339f343877b55b365ae2558806c1be4f7476ed5/coverage-7.10.7-cp39-cp39-win_amd64.whl", hash = "sha256:c134869d5ffe34547d14e174c866fd8fe2254918cc0a95e99052903bc1543e07", size = 221408, upload-time = "2025-09-21T20:03:51.803Z" }, + { url = "https://files.pythonhosted.org/packages/ec/16/114df1c291c22cac3b0c127a73e0af5c12ed7bbb6558d310429a0ae24023/coverage-7.10.7-py3-none-any.whl", hash = "sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260", size = 209952, upload-time = "2025-09-21T20:03:53.918Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version < '3.10'" }, +] + +[[package]] +name = "coverage" +version = "7.12.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/89/26/4a96807b193b011588099c3b5c89fbb05294e5b90e71018e065465f34eb6/coverage-7.12.0.tar.gz", hash = "sha256:fc11e0a4e372cb5f282f16ef90d4a585034050ccda536451901abfb19a57f40c", size = 819341, upload-time = "2025-11-18T13:34:20.766Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/4a/0dc3de1c172d35abe512332cfdcc43211b6ebce629e4cc42e6cd25ed8f4d/coverage-7.12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:32b75c2ba3f324ee37af3ccee5b30458038c50b349ad9b88cee85096132a575b", size = 217409, upload-time = "2025-11-18T13:31:53.122Z" }, + { url = "https://files.pythonhosted.org/packages/01/c3/086198b98db0109ad4f84241e8e9ea7e5fb2db8c8ffb787162d40c26cc76/coverage-7.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cb2a1b6ab9fe833714a483a915de350abc624a37149649297624c8d57add089c", size = 217927, upload-time = "2025-11-18T13:31:54.458Z" }, + { url = "https://files.pythonhosted.org/packages/5d/5f/34614dbf5ce0420828fc6c6f915126a0fcb01e25d16cf141bf5361e6aea6/coverage-7.12.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5734b5d913c3755e72f70bf6cc37a0518d4f4745cde760c5d8e12005e62f9832", size = 244678, upload-time = "2025-11-18T13:31:55.805Z" }, + { url = "https://files.pythonhosted.org/packages/55/7b/6b26fb32e8e4a6989ac1d40c4e132b14556131493b1d06bc0f2be169c357/coverage-7.12.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b527a08cdf15753279b7afb2339a12073620b761d79b81cbe2cdebdb43d90daa", size = 246507, upload-time = "2025-11-18T13:31:57.05Z" }, + { url = "https://files.pythonhosted.org/packages/06/42/7d70e6603d3260199b90fb48b537ca29ac183d524a65cc31366b2e905fad/coverage-7.12.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9bb44c889fb68004e94cab71f6a021ec83eac9aeabdbb5a5a88821ec46e1da73", size = 248366, upload-time = "2025-11-18T13:31:58.362Z" }, + { url = "https://files.pythonhosted.org/packages/2d/4a/d86b837923878424c72458c5b25e899a3c5ca73e663082a915f5b3c4d749/coverage-7.12.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4b59b501455535e2e5dde5881739897967b272ba25988c89145c12d772810ccb", size = 245366, upload-time = "2025-11-18T13:31:59.572Z" }, + { url = "https://files.pythonhosted.org/packages/e6/c2/2adec557e0aa9721875f06ced19730fdb7fc58e31b02b5aa56f2ebe4944d/coverage-7.12.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d8842f17095b9868a05837b7b1b73495293091bed870e099521ada176aa3e00e", size = 246408, upload-time = "2025-11-18T13:32:00.784Z" }, + { url = "https://files.pythonhosted.org/packages/5a/4b/8bd1f1148260df11c618e535fdccd1e5aaf646e55b50759006a4f41d8a26/coverage-7.12.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c5a6f20bf48b8866095c6820641e7ffbe23f2ac84a2efc218d91235e404c7777", size = 244416, upload-time = "2025-11-18T13:32:01.963Z" }, + { url = "https://files.pythonhosted.org/packages/0e/13/3a248dd6a83df90414c54a4e121fd081fb20602ca43955fbe1d60e2312a9/coverage-7.12.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:5f3738279524e988d9da2893f307c2093815c623f8d05a8f79e3eff3a7a9e553", size = 244681, upload-time = "2025-11-18T13:32:03.408Z" }, + { url = "https://files.pythonhosted.org/packages/76/30/aa833827465a5e8c938935f5d91ba055f70516941078a703740aaf1aa41f/coverage-7.12.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e0d68c1f7eabbc8abe582d11fa393ea483caf4f44b0af86881174769f185c94d", size = 245300, upload-time = "2025-11-18T13:32:04.686Z" }, + { url = "https://files.pythonhosted.org/packages/38/24/f85b3843af1370fb3739fa7571819b71243daa311289b31214fe3e8c9d68/coverage-7.12.0-cp310-cp310-win32.whl", hash = "sha256:7670d860e18b1e3ee5930b17a7d55ae6287ec6e55d9799982aa103a2cc1fa2ef", size = 220008, upload-time = "2025-11-18T13:32:05.806Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a2/c7da5b9566f7164db9eefa133d17761ecb2c2fde9385d754e5b5c80f710d/coverage-7.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:f999813dddeb2a56aab5841e687b68169da0d3f6fc78ccf50952fa2463746022", size = 220943, upload-time = "2025-11-18T13:32:07.166Z" }, + { url = "https://files.pythonhosted.org/packages/5a/0c/0dfe7f0487477d96432e4815537263363fb6dd7289743a796e8e51eabdf2/coverage-7.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:aa124a3683d2af98bd9d9c2bfa7a5076ca7e5ab09fdb96b81fa7d89376ae928f", size = 217535, upload-time = "2025-11-18T13:32:08.812Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f5/f9a4a053a5bbff023d3bec259faac8f11a1e5a6479c2ccf586f910d8dac7/coverage-7.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d93fbf446c31c0140208dcd07c5d882029832e8ed7891a39d6d44bd65f2316c3", size = 218044, upload-time = "2025-11-18T13:32:10.329Z" }, + { url = "https://files.pythonhosted.org/packages/95/c5/84fc3697c1fa10cd8571919bf9693f693b7373278daaf3b73e328d502bc8/coverage-7.12.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:52ca620260bd8cd6027317bdd8b8ba929be1d741764ee765b42c4d79a408601e", size = 248440, upload-time = "2025-11-18T13:32:12.536Z" }, + { url = "https://files.pythonhosted.org/packages/f4/36/2d93fbf6a04670f3874aed397d5a5371948a076e3249244a9e84fb0e02d6/coverage-7.12.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f3433ffd541380f3a0e423cff0f4926d55b0cc8c1d160fdc3be24a4c03aa65f7", size = 250361, upload-time = "2025-11-18T13:32:13.852Z" }, + { url = "https://files.pythonhosted.org/packages/5d/49/66dc65cc456a6bfc41ea3d0758c4afeaa4068a2b2931bf83be6894cf1058/coverage-7.12.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7bbb321d4adc9f65e402c677cd1c8e4c2d0105d3ce285b51b4d87f1d5db5245", size = 252472, upload-time = "2025-11-18T13:32:15.068Z" }, + { url = "https://files.pythonhosted.org/packages/35/1f/ebb8a18dffd406db9fcd4b3ae42254aedcaf612470e8712f12041325930f/coverage-7.12.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22a7aade354a72dff3b59c577bfd18d6945c61f97393bc5fb7bd293a4237024b", size = 248592, upload-time = "2025-11-18T13:32:16.328Z" }, + { url = "https://files.pythonhosted.org/packages/da/a8/67f213c06e5ea3b3d4980df7dc344d7fea88240b5fe878a5dcbdfe0e2315/coverage-7.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3ff651dcd36d2fea66877cd4a82de478004c59b849945446acb5baf9379a1b64", size = 250167, upload-time = "2025-11-18T13:32:17.687Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/e52aef68154164ea40cc8389c120c314c747fe63a04b013a5782e989b77f/coverage-7.12.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:31b8b2e38391a56e3cea39d22a23faaa7c3fc911751756ef6d2621d2a9daf742", size = 248238, upload-time = "2025-11-18T13:32:19.2Z" }, + { url = "https://files.pythonhosted.org/packages/1f/a4/4d88750bcf9d6d66f77865e5a05a20e14db44074c25fd22519777cb69025/coverage-7.12.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:297bc2da28440f5ae51c845a47c8175a4db0553a53827886e4fb25c66633000c", size = 247964, upload-time = "2025-11-18T13:32:21.027Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6b/b74693158899d5b47b0bf6238d2c6722e20ba749f86b74454fac0696bb00/coverage-7.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6ff7651cc01a246908eac162a6a86fc0dbab6de1ad165dfb9a1e2ec660b44984", size = 248862, upload-time = "2025-11-18T13:32:22.304Z" }, + { url = "https://files.pythonhosted.org/packages/18/de/6af6730227ce0e8ade307b1cc4a08e7f51b419a78d02083a86c04ccceb29/coverage-7.12.0-cp311-cp311-win32.whl", hash = "sha256:313672140638b6ddb2c6455ddeda41c6a0b208298034544cfca138978c6baed6", size = 220033, upload-time = "2025-11-18T13:32:23.714Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a1/e7f63021a7c4fe20994359fcdeae43cbef4a4d0ca36a5a1639feeea5d9e1/coverage-7.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:a1783ed5bd0d5938d4435014626568dc7f93e3cb99bc59188cc18857c47aa3c4", size = 220966, upload-time = "2025-11-18T13:32:25.599Z" }, + { url = "https://files.pythonhosted.org/packages/77/e8/deae26453f37c20c3aa0c4433a1e32cdc169bf415cce223a693117aa3ddd/coverage-7.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:4648158fd8dd9381b5847622df1c90ff314efbfc1df4550092ab6013c238a5fc", size = 219637, upload-time = "2025-11-18T13:32:27.265Z" }, + { url = "https://files.pythonhosted.org/packages/02/bf/638c0427c0f0d47638242e2438127f3c8ee3cfc06c7fdeb16778ed47f836/coverage-7.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:29644c928772c78512b48e14156b81255000dcfd4817574ff69def189bcb3647", size = 217704, upload-time = "2025-11-18T13:32:28.906Z" }, + { url = "https://files.pythonhosted.org/packages/08/e1/706fae6692a66c2d6b871a608bbde0da6281903fa0e9f53a39ed441da36a/coverage-7.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8638cbb002eaa5d7c8d04da667813ce1067080b9a91099801a0053086e52b736", size = 218064, upload-time = "2025-11-18T13:32:30.161Z" }, + { url = "https://files.pythonhosted.org/packages/a9/8b/eb0231d0540f8af3ffda39720ff43cb91926489d01524e68f60e961366e4/coverage-7.12.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:083631eeff5eb9992c923e14b810a179798bb598e6a0dd60586819fc23be6e60", size = 249560, upload-time = "2025-11-18T13:32:31.835Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a1/67fb52af642e974d159b5b379e4d4c59d0ebe1288677fbd04bbffe665a82/coverage-7.12.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:99d5415c73ca12d558e07776bd957c4222c687b9f1d26fa0e1b57e3598bdcde8", size = 252318, upload-time = "2025-11-18T13:32:33.178Z" }, + { url = "https://files.pythonhosted.org/packages/41/e5/38228f31b2c7665ebf9bdfdddd7a184d56450755c7e43ac721c11a4b8dab/coverage-7.12.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e949ebf60c717c3df63adb4a1a366c096c8d7fd8472608cd09359e1bd48ef59f", size = 253403, upload-time = "2025-11-18T13:32:34.45Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4b/df78e4c8188f9960684267c5a4897836f3f0f20a20c51606ee778a1d9749/coverage-7.12.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d907ddccbca819afa2cd014bc69983b146cca2735a0b1e6259b2a6c10be1e70", size = 249984, upload-time = "2025-11-18T13:32:35.747Z" }, + { url = "https://files.pythonhosted.org/packages/ba/51/bb163933d195a345c6f63eab9e55743413d064c291b6220df754075c2769/coverage-7.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b1518ecbad4e6173f4c6e6c4a46e49555ea5679bf3feda5edb1b935c7c44e8a0", size = 251339, upload-time = "2025-11-18T13:32:37.352Z" }, + { url = "https://files.pythonhosted.org/packages/15/40/c9b29cdb8412c837cdcbc2cfa054547dd83affe6cbbd4ce4fdb92b6ba7d1/coverage-7.12.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:51777647a749abdf6f6fd8c7cffab12de68ab93aab15efc72fbbb83036c2a068", size = 249489, upload-time = "2025-11-18T13:32:39.212Z" }, + { url = "https://files.pythonhosted.org/packages/c8/da/b3131e20ba07a0de4437a50ef3b47840dfabf9293675b0cd5c2c7f66dd61/coverage-7.12.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:42435d46d6461a3b305cdfcad7cdd3248787771f53fe18305548cba474e6523b", size = 249070, upload-time = "2025-11-18T13:32:40.598Z" }, + { url = "https://files.pythonhosted.org/packages/70/81/b653329b5f6302c08d683ceff6785bc60a34be9ae92a5c7b63ee7ee7acec/coverage-7.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5bcead88c8423e1855e64b8057d0544e33e4080b95b240c2a355334bb7ced937", size = 250929, upload-time = "2025-11-18T13:32:42.915Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/250ac3bca9f252a5fb1338b5ad01331ebb7b40223f72bef5b1b2cb03aa64/coverage-7.12.0-cp312-cp312-win32.whl", hash = "sha256:dcbb630ab034e86d2a0f79aefd2be07e583202f41e037602d438c80044957baa", size = 220241, upload-time = "2025-11-18T13:32:44.665Z" }, + { url = "https://files.pythonhosted.org/packages/64/1c/77e79e76d37ce83302f6c21980b45e09f8aa4551965213a10e62d71ce0ab/coverage-7.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:2fd8354ed5d69775ac42986a691fbf68b4084278710cee9d7c3eaa0c28fa982a", size = 221051, upload-time = "2025-11-18T13:32:46.008Z" }, + { url = "https://files.pythonhosted.org/packages/31/f5/641b8a25baae564f9e52cac0e2667b123de961985709a004e287ee7663cc/coverage-7.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:737c3814903be30695b2de20d22bcc5428fdae305c61ba44cdc8b3252984c49c", size = 219692, upload-time = "2025-11-18T13:32:47.372Z" }, + { url = "https://files.pythonhosted.org/packages/b8/14/771700b4048774e48d2c54ed0c674273702713c9ee7acdfede40c2666747/coverage-7.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47324fffca8d8eae7e185b5bb20c14645f23350f870c1649003618ea91a78941", size = 217725, upload-time = "2025-11-18T13:32:49.22Z" }, + { url = "https://files.pythonhosted.org/packages/17/a7/3aa4144d3bcb719bf67b22d2d51c2d577bf801498c13cb08f64173e80497/coverage-7.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ccf3b2ede91decd2fb53ec73c1f949c3e034129d1e0b07798ff1d02ea0c8fa4a", size = 218098, upload-time = "2025-11-18T13:32:50.78Z" }, + { url = "https://files.pythonhosted.org/packages/fc/9c/b846bbc774ff81091a12a10203e70562c91ae71badda00c5ae5b613527b1/coverage-7.12.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b365adc70a6936c6b0582dc38746b33b2454148c02349345412c6e743efb646d", size = 249093, upload-time = "2025-11-18T13:32:52.554Z" }, + { url = "https://files.pythonhosted.org/packages/76/b6/67d7c0e1f400b32c883e9342de4a8c2ae7c1a0b57c5de87622b7262e2309/coverage-7.12.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bc13baf85cd8a4cfcf4a35c7bc9d795837ad809775f782f697bf630b7e200211", size = 251686, upload-time = "2025-11-18T13:32:54.862Z" }, + { url = "https://files.pythonhosted.org/packages/cc/75/b095bd4b39d49c3be4bffbb3135fea18a99a431c52dd7513637c0762fecb/coverage-7.12.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:099d11698385d572ceafb3288a5b80fe1fc58bf665b3f9d362389de488361d3d", size = 252930, upload-time = "2025-11-18T13:32:56.417Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f3/466f63015c7c80550bead3093aacabf5380c1220a2a93c35d374cae8f762/coverage-7.12.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:473dc45d69694069adb7680c405fb1e81f60b2aff42c81e2f2c3feaf544d878c", size = 249296, upload-time = "2025-11-18T13:32:58.074Z" }, + { url = "https://files.pythonhosted.org/packages/27/86/eba2209bf2b7e28c68698fc13437519a295b2d228ba9e0ec91673e09fa92/coverage-7.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:583f9adbefd278e9de33c33d6846aa8f5d164fa49b47144180a0e037f0688bb9", size = 251068, upload-time = "2025-11-18T13:32:59.646Z" }, + { url = "https://files.pythonhosted.org/packages/ec/55/ca8ae7dbba962a3351f18940b359b94c6bafdd7757945fdc79ec9e452dc7/coverage-7.12.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b2089cc445f2dc0af6f801f0d1355c025b76c24481935303cf1af28f636688f0", size = 249034, upload-time = "2025-11-18T13:33:01.481Z" }, + { url = "https://files.pythonhosted.org/packages/7a/d7/39136149325cad92d420b023b5fd900dabdd1c3a0d1d5f148ef4a8cedef5/coverage-7.12.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:950411f1eb5d579999c5f66c62a40961f126fc71e5e14419f004471957b51508", size = 248853, upload-time = "2025-11-18T13:33:02.935Z" }, + { url = "https://files.pythonhosted.org/packages/fe/b6/76e1add8b87ef60e00643b0b7f8f7bb73d4bf5249a3be19ebefc5793dd25/coverage-7.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b1aab7302a87bafebfe76b12af681b56ff446dc6f32ed178ff9c092ca776e6bc", size = 250619, upload-time = "2025-11-18T13:33:04.336Z" }, + { url = "https://files.pythonhosted.org/packages/95/87/924c6dc64f9203f7a3c1832a6a0eee5a8335dbe5f1bdadcc278d6f1b4d74/coverage-7.12.0-cp313-cp313-win32.whl", hash = "sha256:d7e0d0303c13b54db495eb636bc2465b2fb8475d4c8bcec8fe4b5ca454dfbae8", size = 220261, upload-time = "2025-11-18T13:33:06.493Z" }, + { url = "https://files.pythonhosted.org/packages/91/77/dd4aff9af16ff776bf355a24d87eeb48fc6acde54c907cc1ea89b14a8804/coverage-7.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:ce61969812d6a98a981d147d9ac583a36ac7db7766f2e64a9d4d059c2fe29d07", size = 221072, upload-time = "2025-11-18T13:33:07.926Z" }, + { url = "https://files.pythonhosted.org/packages/70/49/5c9dc46205fef31b1b226a6e16513193715290584317fd4df91cdaf28b22/coverage-7.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:bcec6f47e4cb8a4c2dc91ce507f6eefc6a1b10f58df32cdc61dff65455031dfc", size = 219702, upload-time = "2025-11-18T13:33:09.631Z" }, + { url = "https://files.pythonhosted.org/packages/9b/62/f87922641c7198667994dd472a91e1d9b829c95d6c29529ceb52132436ad/coverage-7.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:459443346509476170d553035e4a3eed7b860f4fe5242f02de1010501956ce87", size = 218420, upload-time = "2025-11-18T13:33:11.153Z" }, + { url = "https://files.pythonhosted.org/packages/85/dd/1cc13b2395ef15dbb27d7370a2509b4aee77890a464fb35d72d428f84871/coverage-7.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:04a79245ab2b7a61688958f7a855275997134bc84f4a03bc240cf64ff132abf6", size = 218773, upload-time = "2025-11-18T13:33:12.569Z" }, + { url = "https://files.pythonhosted.org/packages/74/40/35773cc4bb1e9d4658d4fb669eb4195b3151bef3bbd6f866aba5cd5dac82/coverage-7.12.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:09a86acaaa8455f13d6a99221d9654df249b33937b4e212b4e5a822065f12aa7", size = 260078, upload-time = "2025-11-18T13:33:14.037Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ee/231bb1a6ffc2905e396557585ebc6bdc559e7c66708376d245a1f1d330fc/coverage-7.12.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:907e0df1b71ba77463687a74149c6122c3f6aac56c2510a5d906b2f368208560", size = 262144, upload-time = "2025-11-18T13:33:15.601Z" }, + { url = "https://files.pythonhosted.org/packages/28/be/32f4aa9f3bf0b56f3971001b56508352c7753915345d45fab4296a986f01/coverage-7.12.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b57e2d0ddd5f0582bae5437c04ee71c46cd908e7bc5d4d0391f9a41e812dd12", size = 264574, upload-time = "2025-11-18T13:33:17.354Z" }, + { url = "https://files.pythonhosted.org/packages/68/7c/00489fcbc2245d13ab12189b977e0cf06ff3351cb98bc6beba8bd68c5902/coverage-7.12.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58c1c6aa677f3a1411fe6fb28ec3a942e4f665df036a3608816e0847fad23296", size = 259298, upload-time = "2025-11-18T13:33:18.958Z" }, + { url = "https://files.pythonhosted.org/packages/96/b4/f0760d65d56c3bea95b449e02570d4abd2549dc784bf39a2d4721a2d8ceb/coverage-7.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4c589361263ab2953e3c4cd2a94db94c4ad4a8e572776ecfbad2389c626e4507", size = 262150, upload-time = "2025-11-18T13:33:20.644Z" }, + { url = "https://files.pythonhosted.org/packages/c5/71/9a9314df00f9326d78c1e5a910f520d599205907432d90d1c1b7a97aa4b1/coverage-7.12.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:91b810a163ccad2e43b1faa11d70d3cf4b6f3d83f9fd5f2df82a32d47b648e0d", size = 259763, upload-time = "2025-11-18T13:33:22.189Z" }, + { url = "https://files.pythonhosted.org/packages/10/34/01a0aceed13fbdf925876b9a15d50862eb8845454301fe3cdd1df08b2182/coverage-7.12.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:40c867af715f22592e0d0fb533a33a71ec9e0f73a6945f722a0c85c8c1cbe3a2", size = 258653, upload-time = "2025-11-18T13:33:24.239Z" }, + { url = "https://files.pythonhosted.org/packages/8d/04/81d8fd64928acf1574bbb0181f66901c6c1c6279c8ccf5f84259d2c68ae9/coverage-7.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:68b0d0a2d84f333de875666259dadf28cc67858bc8fd8b3f1eae84d3c2bec455", size = 260856, upload-time = "2025-11-18T13:33:26.365Z" }, + { url = "https://files.pythonhosted.org/packages/f2/76/fa2a37bfaeaf1f766a2d2360a25a5297d4fb567098112f6517475eee120b/coverage-7.12.0-cp313-cp313t-win32.whl", hash = "sha256:73f9e7fbd51a221818fd11b7090eaa835a353ddd59c236c57b2199486b116c6d", size = 220936, upload-time = "2025-11-18T13:33:28.165Z" }, + { url = "https://files.pythonhosted.org/packages/f9/52/60f64d932d555102611c366afb0eb434b34266b1d9266fc2fe18ab641c47/coverage-7.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:24cff9d1f5743f67db7ba46ff284018a6e9aeb649b67aa1e70c396aa1b7cb23c", size = 222001, upload-time = "2025-11-18T13:33:29.656Z" }, + { url = "https://files.pythonhosted.org/packages/77/df/c303164154a5a3aea7472bf323b7c857fed93b26618ed9fc5c2955566bb0/coverage-7.12.0-cp313-cp313t-win_arm64.whl", hash = "sha256:c87395744f5c77c866d0f5a43d97cc39e17c7f1cb0115e54a2fe67ca75c5d14d", size = 220273, upload-time = "2025-11-18T13:33:31.415Z" }, + { url = "https://files.pythonhosted.org/packages/bf/2e/fc12db0883478d6e12bbd62d481210f0c8daf036102aa11434a0c5755825/coverage-7.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a1c59b7dc169809a88b21a936eccf71c3895a78f5592051b1af8f4d59c2b4f92", size = 217777, upload-time = "2025-11-18T13:33:32.86Z" }, + { url = "https://files.pythonhosted.org/packages/1f/c1/ce3e525d223350c6ec16b9be8a057623f54226ef7f4c2fee361ebb6a02b8/coverage-7.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8787b0f982e020adb732b9f051f3e49dd5054cebbc3f3432061278512a2b1360", size = 218100, upload-time = "2025-11-18T13:33:34.532Z" }, + { url = "https://files.pythonhosted.org/packages/15/87/113757441504aee3808cb422990ed7c8bcc2d53a6779c66c5adef0942939/coverage-7.12.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5ea5a9f7dc8877455b13dd1effd3202e0bca72f6f3ab09f9036b1bcf728f69ac", size = 249151, upload-time = "2025-11-18T13:33:36.135Z" }, + { url = "https://files.pythonhosted.org/packages/d9/1d/9529d9bd44049b6b05bb319c03a3a7e4b0a8a802d28fa348ad407e10706d/coverage-7.12.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fdba9f15849534594f60b47c9a30bc70409b54947319a7c4fd0e8e3d8d2f355d", size = 251667, upload-time = "2025-11-18T13:33:37.996Z" }, + { url = "https://files.pythonhosted.org/packages/11/bb/567e751c41e9c03dc29d3ce74b8c89a1e3396313e34f255a2a2e8b9ebb56/coverage-7.12.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a00594770eb715854fb1c57e0dea08cce6720cfbc531accdb9850d7c7770396c", size = 253003, upload-time = "2025-11-18T13:33:39.553Z" }, + { url = "https://files.pythonhosted.org/packages/e4/b3/c2cce2d8526a02fb9e9ca14a263ca6fc074449b33a6afa4892838c903528/coverage-7.12.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5560c7e0d82b42eb1951e4f68f071f8017c824ebfd5a6ebe42c60ac16c6c2434", size = 249185, upload-time = "2025-11-18T13:33:42.086Z" }, + { url = "https://files.pythonhosted.org/packages/0e/a7/967f93bb66e82c9113c66a8d0b65ecf72fc865adfba5a145f50c7af7e58d/coverage-7.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d6c2e26b481c9159c2773a37947a9718cfdc58893029cdfb177531793e375cfc", size = 251025, upload-time = "2025-11-18T13:33:43.634Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b2/f2f6f56337bc1af465d5b2dc1ee7ee2141b8b9272f3bf6213fcbc309a836/coverage-7.12.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6e1a8c066dabcde56d5d9fed6a66bc19a2883a3fe051f0c397a41fc42aedd4cc", size = 248979, upload-time = "2025-11-18T13:33:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7a/bf4209f45a4aec09d10a01a57313a46c0e0e8f4c55ff2965467d41a92036/coverage-7.12.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f7ba9da4726e446d8dd8aae5a6cd872511184a5d861de80a86ef970b5dacce3e", size = 248800, upload-time = "2025-11-18T13:33:47.546Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b7/1e01b8696fb0521810f60c5bbebf699100d6754183e6cc0679bf2ed76531/coverage-7.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e0f483ab4f749039894abaf80c2f9e7ed77bbf3c737517fb88c8e8e305896a17", size = 250460, upload-time = "2025-11-18T13:33:49.537Z" }, + { url = "https://files.pythonhosted.org/packages/71/ae/84324fb9cb46c024760e706353d9b771a81b398d117d8c1fe010391c186f/coverage-7.12.0-cp314-cp314-win32.whl", hash = "sha256:76336c19a9ef4a94b2f8dc79f8ac2da3f193f625bb5d6f51a328cd19bfc19933", size = 220533, upload-time = "2025-11-18T13:33:51.16Z" }, + { url = "https://files.pythonhosted.org/packages/e2/71/1033629deb8460a8f97f83e6ac4ca3b93952e2b6f826056684df8275e015/coverage-7.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:7c1059b600aec6ef090721f8f633f60ed70afaffe8ecab85b59df748f24b31fe", size = 221348, upload-time = "2025-11-18T13:33:52.776Z" }, + { url = "https://files.pythonhosted.org/packages/0a/5f/ac8107a902f623b0c251abdb749be282dc2ab61854a8a4fcf49e276fce2f/coverage-7.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:172cf3a34bfef42611963e2b661302a8931f44df31629e5b1050567d6b90287d", size = 219922, upload-time = "2025-11-18T13:33:54.316Z" }, + { url = "https://files.pythonhosted.org/packages/79/6e/f27af2d4da367f16077d21ef6fe796c874408219fa6dd3f3efe7751bd910/coverage-7.12.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:aa7d48520a32cb21c7a9b31f81799e8eaec7239db36c3b670be0fa2403828d1d", size = 218511, upload-time = "2025-11-18T13:33:56.343Z" }, + { url = "https://files.pythonhosted.org/packages/67/dd/65fd874aa460c30da78f9d259400d8e6a4ef457d61ab052fd248f0050558/coverage-7.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:90d58ac63bc85e0fb919f14d09d6caa63f35a5512a2205284b7816cafd21bb03", size = 218771, upload-time = "2025-11-18T13:33:57.966Z" }, + { url = "https://files.pythonhosted.org/packages/55/e0/7c6b71d327d8068cb79c05f8f45bf1b6145f7a0de23bbebe63578fe5240a/coverage-7.12.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ca8ecfa283764fdda3eae1bdb6afe58bf78c2c3ec2b2edcb05a671f0bba7b3f9", size = 260151, upload-time = "2025-11-18T13:33:59.597Z" }, + { url = "https://files.pythonhosted.org/packages/49/ce/4697457d58285b7200de6b46d606ea71066c6e674571a946a6ea908fb588/coverage-7.12.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:874fe69a0785d96bd066059cd4368022cebbec1a8958f224f0016979183916e6", size = 262257, upload-time = "2025-11-18T13:34:01.166Z" }, + { url = "https://files.pythonhosted.org/packages/2f/33/acbc6e447aee4ceba88c15528dbe04a35fb4d67b59d393d2e0d6f1e242c1/coverage-7.12.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b3c889c0b8b283a24d721a9eabc8ccafcfc3aebf167e4cd0d0e23bf8ec4e339", size = 264671, upload-time = "2025-11-18T13:34:02.795Z" }, + { url = "https://files.pythonhosted.org/packages/87/ec/e2822a795c1ed44d569980097be839c5e734d4c0c1119ef8e0a073496a30/coverage-7.12.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8bb5b894b3ec09dcd6d3743229dc7f2c42ef7787dc40596ae04c0edda487371e", size = 259231, upload-time = "2025-11-18T13:34:04.397Z" }, + { url = "https://files.pythonhosted.org/packages/72/c5/a7ec5395bb4a49c9b7ad97e63f0c92f6bf4a9e006b1393555a02dae75f16/coverage-7.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:79a44421cd5fba96aa57b5e3b5a4d3274c449d4c622e8f76882d76635501fd13", size = 262137, upload-time = "2025-11-18T13:34:06.068Z" }, + { url = "https://files.pythonhosted.org/packages/67/0c/02c08858b764129f4ecb8e316684272972e60777ae986f3865b10940bdd6/coverage-7.12.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:33baadc0efd5c7294f436a632566ccc1f72c867f82833eb59820ee37dc811c6f", size = 259745, upload-time = "2025-11-18T13:34:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/5a/04/4fd32b7084505f3829a8fe45c1a74a7a728cb251aaadbe3bec04abcef06d/coverage-7.12.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c406a71f544800ef7e9e0000af706b88465f3573ae8b8de37e5f96c59f689ad1", size = 258570, upload-time = "2025-11-18T13:34:09.676Z" }, + { url = "https://files.pythonhosted.org/packages/48/35/2365e37c90df4f5342c4fa202223744119fe31264ee2924f09f074ea9b6d/coverage-7.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e71bba6a40883b00c6d571599b4627f50c360b3d0d02bfc658168936be74027b", size = 260899, upload-time = "2025-11-18T13:34:11.259Z" }, + { url = "https://files.pythonhosted.org/packages/05/56/26ab0464ca733fa325e8e71455c58c1c374ce30f7c04cebb88eabb037b18/coverage-7.12.0-cp314-cp314t-win32.whl", hash = "sha256:9157a5e233c40ce6613dead4c131a006adfda70e557b6856b97aceed01b0e27a", size = 221313, upload-time = "2025-11-18T13:34:12.863Z" }, + { url = "https://files.pythonhosted.org/packages/da/1c/017a3e1113ed34d998b27d2c6dba08a9e7cb97d362f0ec988fcd873dcf81/coverage-7.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e84da3a0fd233aeec797b981c51af1cabac74f9bd67be42458365b30d11b5291", size = 222423, upload-time = "2025-11-18T13:34:15.14Z" }, + { url = "https://files.pythonhosted.org/packages/4c/36/bcc504fdd5169301b52568802bb1b9cdde2e27a01d39fbb3b4b508ab7c2c/coverage-7.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:01d24af36fedda51c2b1aca56e4330a3710f83b02a5ff3743a6b015ffa7c9384", size = 220459, upload-time = "2025-11-18T13:34:17.222Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a3/43b749004e3c09452e39bb56347a008f0a0668aad37324a99b5c8ca91d9e/coverage-7.12.0-py3-none-any.whl", hash = "sha256:159d50c0b12e060b15ed3d39f87ed43d4f7f7ad40b8a534f4dd331adbb51104a", size = 209503, upload-time = "2025-11-18T13:34:18.892Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version >= '3.10' and python_full_version <= '3.11'" }, +] + [[package]] name = "cryptography" version = "46.0.2" @@ -497,6 +725,7 @@ dev = [ [package.dev-dependencies] dev = [ { name = "pytest" }, + { name = "pytest-cov" }, ] [package.metadata] @@ -518,7 +747,10 @@ requires-dist = [ provides-extras = ["dev"] [package.metadata.requires-dev] -dev = [{ name = "pytest", specifier = ">=8.4.2" }] +dev = [ + { name = "pytest", specifier = ">=8.4.2" }, + { name = "pytest-cov", specifier = ">=6.0.0" }, +] [[package]] name = "frozendict" @@ -1720,6 +1952,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, ] +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", version = "7.10.7", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version < '3.10'" }, + { name = "coverage", version = "7.12.0", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version >= '3.10'" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" diff --git a/app/web/components/AddPositionDialog.tsx b/app/web/components/AddPositionDialog.tsx index f632d38..2818a5f 100644 --- a/app/web/components/AddPositionDialog.tsx +++ b/app/web/components/AddPositionDialog.tsx @@ -14,6 +14,7 @@ import { import { IconAlertCircle } from '@tabler/icons-react'; import { portfolioApi } from '@/lib/api'; import type { PostPositionRequest } from '@/types/portfolio'; +import { useGamificationEvents } from '@/hooks/useGamificationEvents'; interface AddPositionDialogProps { opened: boolean; @@ -28,6 +29,7 @@ export const AddPositionDialog = ({ opened, onClose, onSuccess }: AddPositionDia const [executedAt, setExecutedAt] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const { triggerPortfolioPositionAdded } = useGamificationEvents(); const handleSubmit = async () => { // Validation @@ -57,6 +59,9 @@ export const AddPositionDialog = ({ opened, onClose, onSuccess }: AddPositionDia await portfolioApi.addPosition(request); + // Trigger gamification event (position_id is optional) + await triggerPortfolioPositionAdded(); + // Reset form setSymbol(''); setQuantity(0); diff --git a/app/web/components/AllocationChart.tsx b/app/web/components/AllocationChart.tsx index 3dac1db..c12178c 100644 --- a/app/web/components/AllocationChart.tsx +++ b/app/web/components/AllocationChart.tsx @@ -60,13 +60,30 @@ export const AllocationChart = ({ data, title, colors = DEFAULT_COLORS }: Alloca ))} `${value.toFixed(2)}%`} - contentStyle={{ - backgroundColor: 'rgba(255, 255, 255, 0.98)', - border: '1px solid #e5e7eb', - borderRadius: '8px', - padding: '8px 12px', - boxShadow: '0 4px 12px rgba(0, 0, 0, 0.08)', + content={({ active, payload }) => { + if (active && payload && payload.length > 0) { + const entry = payload[0]; + const name = typeof entry.name === 'string' ? entry.name : String(entry.name || ''); + const uppercaseName = name.toUpperCase(); + const value = typeof entry.value === 'number' ? entry.value : Number(entry.value || 0); + return ( +
+
+ {uppercaseName} +
+
+ {value.toFixed(2)}% +
+
+ ); + } + return null; }} /> @@ -93,7 +110,14 @@ export const AllocationChart = ({ data, title, colors = DEFAULT_COLORS }: Alloca flexShrink: 0 }} /> - {entry.name} + + {entry.name} + {entry.value.toFixed(0)}% diff --git a/app/web/components/AppNav.tsx b/app/web/components/AppNav.tsx index d78e77b..e3db522 100644 --- a/app/web/components/AppNav.tsx +++ b/app/web/components/AppNav.tsx @@ -2,13 +2,16 @@ * Application Navigation Component */ import { useState, useEffect } from 'react'; -import { AppShell, Group, Button, Text, Container, Menu, Avatar, ActionIcon, Tooltip } from '@mantine/core'; +import { AppShell, Group, Button, Text, Container, Menu, Avatar, ActionIcon, Tooltip, Badge, Box, Skeleton } from '@mantine/core'; import { useMantineColorScheme } from '@mantine/core'; import { IconLogout, IconUser, IconSun, IconMoon } from '@tabler/icons-react'; import Image from 'next/image'; import { useRouter } from 'next/router'; import FinQuestLogo from '../assets/FinQuestLogo.png'; import { useAuth } from '@/contexts/AuthContext'; +import { useGamification } from '@/contexts/GamificationContext'; +import { XPBar } from './XPBar'; +import { StreakIndicator } from './StreakIndicator'; const ColorSchemeToggle = () => { const { colorScheme, setColorScheme } = useMantineColorScheme(); @@ -34,9 +37,16 @@ const ColorSchemeToggle = () => { ); }; +const getLevelBorderColor = (level: number): string => { + if (level >= 7) return '#f6d365'; // Gold + if (level >= 4) return '#667eea'; // Blue + return '#9ca3af'; // Grey +}; + export const AppNav = () => { const router = useRouter(); const { user, signOut } = useAuth(); + const { level, loading } = useGamification(); const handleSignOut = async () => { await signOut(); @@ -78,18 +88,64 @@ export const AppNav = () => { Learn - + + + - + - - {user?.email?.charAt(0).toUpperCase() || 'U'} - + + {loading ? ( + <> + + + + ) : ( + <> + = 7 ? `0 0 8px ${getLevelBorderColor(level)}` : 'none', + }} + > + {user?.email?.charAt(0).toUpperCase() || 'U'} + + = 7 ? '#000' : '#fff', + minWidth: '20px', + height: '20px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }} + > + {level} + + + )} + {user?.email} diff --git a/app/web/components/BadgeEarnedModal.tsx b/app/web/components/BadgeEarnedModal.tsx new file mode 100644 index 0000000..b2fb4bb --- /dev/null +++ b/app/web/components/BadgeEarnedModal.tsx @@ -0,0 +1,68 @@ +/** + * Badge Earned Modal Component + */ +import { motion, AnimatePresence } from 'framer-motion'; +import { Modal, Text, Button, Stack, Title } from '@mantine/core'; +import type { BadgeInfo } from '@/lib/api'; + +interface BadgeEarnedModalProps { + badges: BadgeInfo[]; + opened: boolean; + onClose: () => void; +} + +export const BadgeEarnedModal = ({ badges, opened, onClose }: BadgeEarnedModalProps) => { + if (badges.length === 0) return null; + + return ( + + {opened && ( + + 🏆 Badge Earned! + + } + centered + size="md" + styles={{ + content: { + overflow: 'visible', + }, + }} + > + + + {badges.map((badge) => ( +
+
🏅
+ + {badge.name} + + + {badge.description} + +
+ ))} + +
+
+
+ )} +
+ ); +}; + diff --git a/app/web/components/BadgesGrid.tsx b/app/web/components/BadgesGrid.tsx new file mode 100644 index 0000000..0518cc0 --- /dev/null +++ b/app/web/components/BadgesGrid.tsx @@ -0,0 +1,96 @@ +/** + * Badges Grid Component for Profile Page + */ +import { useEffect, useState } from 'react'; +import { Grid, Card, Text, Badge, Stack, Title } from '@mantine/core'; +import { gamificationApi, type BadgeDefinitionResponse } from '@/lib/api'; + +export const BadgesGrid = () => { + const [badges, setBadges] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchBadges = async () => { + try { + const data = await gamificationApi.getBadges(); + setBadges(data); + } catch (error) { + console.error('Failed to fetch badges:', error); + } finally { + setLoading(false); + } + }; + + fetchBadges(); + }, []); + + if (loading) { + return Loading badges...; + } + + const earnedBadges = badges.filter((b) => b.earned); + const unearnedBadges = badges.filter((b) => !b.earned && b.is_active); + + return ( + +
+ + Your Badges ({earnedBadges.length}) + + {earnedBadges.length === 0 ? ( + No badges earned yet. Keep learning to earn your first badge! + ) : ( + + {earnedBadges.map((badge) => ( + + + +
🏅
+ + {badge.name} + + + {badge.description} + + + Earned + +
+
+
+ ))} +
+ )} +
+ + {unearnedBadges.length > 0 && ( +
+ + Available Badges + + + {unearnedBadges.map((badge) => ( + + + +
🏅
+ + {badge.name} + + + {badge.description} + + + Locked + +
+
+
+ ))} +
+
+ )} +
+ ); +}; + diff --git a/app/web/components/GamificationEngagement.tsx b/app/web/components/GamificationEngagement.tsx new file mode 100644 index 0000000..c9786cd --- /dev/null +++ b/app/web/components/GamificationEngagement.tsx @@ -0,0 +1,149 @@ +/** + * Gamification Engagement Component + * Displays streak, level progress, and encourages module completion + */ +import { Paper, Group, Text, Button, Stack, useMantineColorScheme, Skeleton } from '@mantine/core'; +import { IconFlame, IconTrendingUp, IconBook, IconSparkles } from '@tabler/icons-react'; +import { useGamification } from '@/contexts/GamificationContext'; +import { useRouter } from 'next/router'; +import type { Suggestion } from '@/types/learning'; + +interface GamificationEngagementProps { + suggestions?: Suggestion[]; + loadingSuggestions?: boolean; +} + +export const GamificationEngagement = ({ + suggestions = [], + loadingSuggestions = false +}: GamificationEngagementProps) => { + const { currentStreak, xpToNextLevel, loading } = useGamification(); + const { colorScheme } = useMantineColorScheme(); + const router = useRouter(); + const isDark = colorScheme === 'dark'; + + // Check if close to leveling up (within 50 XP) + const isCloseToLevelUp = xpToNextLevel <= 50 && xpToNextLevel > 0; + + // Check if there are available modules + const hasAvailableModules = suggestions.length > 0; + + if (loading || loadingSuggestions) { + return ( + + + + + + + + + + + + + ); + } + + // Don't show if no streak and not close to level up and no modules + if (currentStreak === 0 && !isCloseToLevelUp && !hasAvailableModules) { + return null; + } + + const getEncouragementMessage = () => { + if (currentStreak > 0 && isCloseToLevelUp && hasAvailableModules) { + return `🔥 ${currentStreak}-day streak! You're ${xpToNextLevel} XP away from leveling up. Complete a module to level up!`; + } + if (currentStreak > 0 && isCloseToLevelUp) { + return `🔥 ${currentStreak}-day streak! You're ${xpToNextLevel} XP away from leveling up!`; + } + if (currentStreak > 0 && hasAvailableModules) { + return `🔥 ${currentStreak}-day streak! Keep it up! Complete a new module to earn more XP.`; + } + if (currentStreak > 0) { + return `🔥 ${currentStreak}-day streak! Keep it up!`; + } + if (isCloseToLevelUp && hasAvailableModules) { + return `You're ${xpToNextLevel} XP away from leveling up! Complete a module to level up!`; + } + if (isCloseToLevelUp) { + return `You're ${xpToNextLevel} XP away from leveling up!`; + } + if (hasAvailableModules) { + return 'Complete a new module to earn XP and level up!'; + } + return null; + }; + + const message = getEncouragementMessage(); + if (!message) return null; + + return ( + + + + {currentStreak > 0 && ( + + )} + {isCloseToLevelUp && currentStreak === 0 && ( + + )} + {!currentStreak && !isCloseToLevelUp && ( + + )} + + {message} + + + {hasAvailableModules && ( + + )} + + + ); +}; + diff --git a/app/web/components/LearningPathway.tsx b/app/web/components/LearningPathway.tsx new file mode 100644 index 0000000..b39a3a8 --- /dev/null +++ b/app/web/components/LearningPathway.tsx @@ -0,0 +1,436 @@ +import { Card, Text, Button, Badge, Group, ThemeIcon, Stack, Box, useMantineColorScheme, useMantineTheme } from "@mantine/core"; +import { IconBook, IconChartBar, IconAlertTriangle, IconArrowRight, IconCheck } from "@tabler/icons-react"; +import type { Suggestion } from "@/types/learning"; +import { useRouter } from "next/router"; +import { useState, useEffect, useRef } from "react"; + +interface LearningPathwayProps { + suggestions: Suggestion[]; +} + +const getIcon = (type: string | undefined) => { + switch (type) { + case "investment": + return ; + case "warning": + return ; + default: + return ; + } +}; + +const getColor = (type: string | undefined, isCompleted: boolean) => { + if (isCompleted) return "gray"; + switch (type) { + case "investment": + return "blue"; + case "warning": + return "orange"; + default: + return "teal"; + } +}; + +const getConfidenceLabel = (confidence: number | null) => { + if (!confidence) return { label: 'Low Match', color: 'gray' }; + if (confidence >= 0.8) return { label: 'High Match', color: 'green' }; + if (confidence >= 0.5) return { label: 'Medium Match', color: 'yellow' }; + return { label: 'Low Match', color: 'gray' }; +}; + +const toTitleCase = (str: string): string => { + if (!str) return str; + + // Words that should remain lowercase unless they're the first word + const smallWords = ['a', 'an', 'and', 'as', 'at', 'but', 'by', 'for', 'if', 'in', 'of', 'on', 'or', 'the', 'to', 'with']; + + return str + .toLowerCase() + .split(' ') + .map((word, index) => { + // Always capitalize the first word, or capitalize if it's not a small word + if (index === 0 || !smallWords.includes(word)) { + return word.charAt(0).toUpperCase() + word.slice(1); + } + return word; + }) + .join(' '); +}; + +export const LearningPathway = ({ suggestions }: LearningPathwayProps) => { + const router = useRouter(); + const { colorScheme } = useMantineColorScheme(); + const theme = useMantineTheme(); + const [backgroundPosition, setBackgroundPosition] = useState({ x: 0, y: 0 }); + const containerRef = useRef(null); + const targetPositionRef = useRef({ x: 0, y: 0 }); + const animationFrameRef = useRef(null); + const firstIncompleteModuleRef = useRef(null); + const isDark = colorScheme === 'dark'; + + // Keep suggestions in their original order to preserve pathway sequence + const sortedSuggestions = [...suggestions]; + const hasScrolledRef = useRef(false); + const prevSuggestionsLengthRef = useRef(suggestions.length); + + // Scroll to first incomplete module when navigating to the page + useEffect(() => { + // Reset scroll flag if suggestions changed significantly (new data loaded) + if (prevSuggestionsLengthRef.current !== suggestions.length) { + hasScrolledRef.current = false; + prevSuggestionsLengthRef.current = suggestions.length; + } + + if (firstIncompleteModuleRef.current && !hasScrolledRef.current && suggestions.length > 0) { + // Small delay to ensure DOM is ready + const timer = setTimeout(() => { + firstIncompleteModuleRef.current?.scrollIntoView({ + behavior: 'smooth', + block: 'center', + }); + hasScrolledRef.current = true; + }, 300); + return () => clearTimeout(timer); + } + }, [suggestions]); + + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + if (containerRef.current) { + const rect = containerRef.current.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + // Calculate target position (subtle movement) + targetPositionRef.current = { + x: x * 0.02, + y: y * 0.02, + }; + } + }; + + const animate = () => { + // Smooth interpolation towards target position + const currentX = backgroundPosition.x; + const currentY = backgroundPosition.y; + const targetX = targetPositionRef.current.x; + const targetY = targetPositionRef.current.y; + + // Easing factor (0.1 = smooth, higher = faster) + const easing = 0.1; + const newX = currentX + (targetX - currentX) * easing; + const newY = currentY + (targetY - currentY) * easing; + + setBackgroundPosition({ x: newX, y: newY }); + animationFrameRef.current = requestAnimationFrame(animate); + }; + + const container = containerRef.current; + if (container) { + container.addEventListener('mousemove', handleMouseMove); + animationFrameRef.current = requestAnimationFrame(animate); + + return () => { + container.removeEventListener('mousemove', handleMouseMove); + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } + }; + } + }, [backgroundPosition.x, backgroundPosition.y]); + + return ( + + + + {sortedSuggestions.map((suggestion, index) => { + const isCompleted = suggestion.status === "completed"; + const isLast = index === sortedSuggestions.length - 1; + const nextSuggestion = sortedSuggestions[index + 1]; + const nextIsCompleted = nextSuggestion?.status === "completed"; + const color = getColor(suggestion.metadata?.type, isCompleted); + const icon = getIcon(suggestion.metadata?.type); + + // Determine line color based on completion status and theme + const getLineGradient = () => { + if (isDark) { + if (isCompleted && nextIsCompleted) { + return 'linear-gradient(to bottom, #51cf66, #69db7c)'; // Green for completed path + } else if (isCompleted && !nextIsCompleted) { + return 'linear-gradient(to bottom, #51cf66, #4dabf7)'; // Transition from completed to active (lighter blue for dark mode) + } else if (!isCompleted && nextIsCompleted) { + return 'linear-gradient(to bottom, #4dabf7, #51cf66)'; // Transition from active to completed + } else { + return 'linear-gradient(to bottom, #4dabf7, #74c0fc)'; // Active path (lighter blue for dark mode) + } + } else { + if (isCompleted && nextIsCompleted) { + return 'linear-gradient(to bottom, #51cf66, #69db7c)'; // Green for completed path + } else if (isCompleted && !nextIsCompleted) { + return 'linear-gradient(to bottom, #51cf66, #228be6)'; // Transition from completed to active + } else if (!isCompleted && nextIsCompleted) { + return 'linear-gradient(to bottom, #228be6, #51cf66)'; // Transition from active to completed + } else { + return 'linear-gradient(to bottom, #228be6, #74c0fc)'; // Active path + } + } + }; + + // Check if this is the first incomplete module + const isFirstIncomplete = !isCompleted && + sortedSuggestions.slice(0, index).every(s => s.status === "completed"); + + return ( + + {/* Module Card */} + { + if (!isCompleted) { + e.currentTarget.style.transform = 'translateY(-4px)'; + e.currentTarget.style.boxShadow = isDark + ? '0 8px 16px rgba(0, 0, 0, 0.4)' + : '0 8px 16px rgba(0, 0, 0, 0.15)'; + } + }} + onMouseLeave={(e) => { + e.currentTarget.style.transform = 'translateY(0)'; + e.currentTarget.style.boxShadow = ''; + }} + > + + + {/* Header */} + + + + {icon} + {isCompleted && ( + + + + )} + +
+ + {toTitleCase(suggestion.metadata?.topic || "General Learning")} + + {isCompleted && ( + + Completed + + )} +
+
+ {!isCompleted && suggestion.confidence && ( + + {getConfidenceLabel(suggestion.confidence).label} + + )} +
+ + {/* Description */} + + {suggestion.reason} + + + {/* Action Button */} + {isCompleted ? ( + + ) : ( + + )} +
+
+
+ + {/* Connecting line - positioned right after the card */} + {!isLast && ( + + )} + + ); + })} + + {/* Dashed line and message at the end */} + {sortedSuggestions.length > 0 && ( + + {/* Dashed connecting line */} + + + {/* Message card */} + + + + + + + More modules will be generated automatically after you complete the existing ones. + + + + + )} +
+
+
+ ); +}; + diff --git a/app/web/components/LevelUpModal.tsx b/app/web/components/LevelUpModal.tsx new file mode 100644 index 0000000..b22f4c7 --- /dev/null +++ b/app/web/components/LevelUpModal.tsx @@ -0,0 +1,72 @@ +/** + * Level Up Modal Component + */ +import { motion, AnimatePresence } from 'framer-motion'; +import { Modal, Text, Button, Stack, Title } from '@mantine/core'; +import { useMantineColorScheme } from '@mantine/core'; + +interface LevelUpModalProps { + opened: boolean; + onClose: () => void; + newLevel: number; +} + +export const LevelUpModal = ({ opened, onClose, newLevel }: LevelUpModalProps) => { + const { colorScheme } = useMantineColorScheme(); + const isDark = colorScheme === 'dark'; + + return ( + + {opened && ( + + + + + 🎉 + + + Level Up! + + + You've reached Level {newLevel}! + + + Keep learning and earning XP to level up even more! + + + + + + )} + + ); +}; + + diff --git a/app/web/components/ModuleViewer.tsx b/app/web/components/ModuleViewer.tsx index f8a825a..e96ac48 100644 --- a/app/web/components/ModuleViewer.tsx +++ b/app/web/components/ModuleViewer.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import { Paper, Title, @@ -11,6 +11,7 @@ import { Progress, Box, ThemeIcon, + useMantineColorScheme, } from "@mantine/core"; import { IconCheck, IconX, IconBulb } from "@tabler/icons-react"; import ReactMarkdown from "react-markdown"; @@ -39,6 +40,8 @@ export interface ModuleViewerProps { } export const ModuleViewer = ({ content, onComplete }: ModuleViewerProps) => { + const { colorScheme } = useMantineColorScheme(); + const isDark = colorScheme === "dark"; const [activeTab, setActiveTab] = useState<"learn" | "quiz">("learn"); const [currentQuestion, setCurrentQuestion] = useState(0); const [selectedAnswer, setSelectedAnswer] = useState(null); @@ -46,6 +49,12 @@ export const ModuleViewer = ({ content, onComplete }: ModuleViewerProps) => { const [score, setScore] = useState(0); const [quizCompleted, setQuizCompleted] = useState(false); + // Reset selected answer when question changes + useEffect(() => { + setSelectedAnswer(null); + setShowResult(false); + }, [currentQuestion]); + const handleAnswerSubmit = () => { if (!selectedAnswer) return; @@ -62,10 +71,12 @@ export const ModuleViewer = ({ content, onComplete }: ModuleViewerProps) => { }; const handleNextQuestion = () => { + // Clear selected answer first + setSelectedAnswer(null); + setShowResult(false); + if (currentQuestion < content.questions.length - 1) { setCurrentQuestion(currentQuestion + 1); - setSelectedAnswer(null); - setShowResult(false); } else { setQuizCompleted(true); if (onComplete) { @@ -128,9 +139,9 @@ export const ModuleViewer = ({ content, onComplete }: ModuleViewerProps) => { > {passed ? : } - + {passed ? "Module Completed!" : "Keep Trying!"} - + You scored {score} out of {content.questions.length} ({percentage}%) @@ -143,9 +154,9 @@ export const ModuleViewer = ({ content, onComplete }: ModuleViewerProps) => { {!passed && ( - diff --git a/app/web/components/StreakIndicator.tsx b/app/web/components/StreakIndicator.tsx new file mode 100644 index 0000000..6f05b47 --- /dev/null +++ b/app/web/components/StreakIndicator.tsx @@ -0,0 +1,82 @@ +/** + * Streak Indicator Component + */ +import { motion } from 'framer-motion'; +import { Group, Text, useMantineColorScheme, Tooltip, Skeleton } from '@mantine/core'; +import { IconFlame } from '@tabler/icons-react'; +import { useGamification } from '@/contexts/GamificationContext'; + +interface StreakIndicatorProps { + compact?: boolean; +} + +export const StreakIndicator = ({ compact = false }: StreakIndicatorProps) => { + const { currentStreak, loading } = useGamification(); + const { colorScheme } = useMantineColorScheme(); + const isDark = colorScheme === 'dark'; + + // Show skeleton if loading + if (loading) { + if (compact) { + return ( + + + + + ); + } + return ( + + + + + ); + } + + if (currentStreak === 0) { + return null; + } + + const tooltipText = `Daily streak: ${currentStreak} ${currentStreak === 1 ? 'day' : 'days'}`; + + if (compact) { + const streakContent = ( + + + + + {currentStreak} + + + + ); + + return ( + + {streakContent} + + ); + } + + return ( + + + + + {currentStreak}-day streak + + + + ); +}; + diff --git a/app/web/components/StreakModal.tsx b/app/web/components/StreakModal.tsx new file mode 100644 index 0000000..91e83a8 --- /dev/null +++ b/app/web/components/StreakModal.tsx @@ -0,0 +1,72 @@ +/** + * Streak Increment Modal Component + */ +import { motion, AnimatePresence } from 'framer-motion'; +import { Modal, Text, Button, Stack, Title } from '@mantine/core'; +import { useMantineColorScheme } from '@mantine/core'; + +interface StreakModalProps { + opened: boolean; + onClose: () => void; + streak: number; +} + +export const StreakModal = ({ opened, onClose, streak }: StreakModalProps) => { + const { colorScheme } = useMantineColorScheme(); + const isDark = colorScheme === 'dark'; + + return ( + + {opened && ( + + + + + 🔥 + + + Streak Increased! + + + {streak}-Day Streak + + + Keep it up! Complete a quiz every day to maintain your streak. + + + + + + )} + + ); +}; + + diff --git a/app/web/components/SuggestionsWidget.tsx b/app/web/components/SuggestionsWidget.tsx index 1ad2b37..b34ec06 100644 --- a/app/web/components/SuggestionsWidget.tsx +++ b/app/web/components/SuggestionsWidget.tsx @@ -48,12 +48,15 @@ export const SuggestionsWidget = ({ suggestions, loading }: SuggestionsWidgetPro ); } - if (suggestions.length === 0) { + // Filter out completed suggestions - only show incomplete ones for this widget + const incompleteSuggestions = suggestions.filter(s => s.status !== 'completed'); + + if (incompleteSuggestions.length === 0) { return null; } - // Show only top 3 suggestions - const topSuggestions = suggestions.slice(0, 3); + // Show only top 3 incomplete suggestions + const topSuggestions = incompleteSuggestions.slice(0, 3); return ( diff --git a/app/web/components/ValueChart.tsx b/app/web/components/ValueChart.tsx index eca85cc..7a759c7 100644 --- a/app/web/components/ValueChart.tsx +++ b/app/web/components/ValueChart.tsx @@ -281,23 +281,30 @@ export const ValueChart = ({ width={70} /> [`${baseCurrency} ${value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`, '']} - labelFormatter={(label, payload) => { - if (payload && payload[0]) { + content={({ active, payload }) => { + if (active && payload && payload.length > 0) { const tooltipFormat = getTooltipFormat(granularity); - return format(new Date(payload[0].payload.fullDate), tooltipFormat); + const date = payload[0].payload.fullDate; + const value = payload[0].value as number; + return ( +
+
+ {format(new Date(date), tooltipFormat)} +
+
+ {formatCurrency(value, baseCurrency)} +
+
+ ); } - return label; + return null; }} - contentStyle={{ - backgroundColor: 'rgba(255, 255, 255, 0.98)', - border: '1px solid #e5e7eb', - borderRadius: '8px', - padding: '10px 14px', - boxShadow: '0 4px 12px rgba(0, 0, 0, 0.08)', - }} - labelStyle={{ fontWeight: 600, marginBottom: '6px', color: '#111827' }} - itemStyle={{ color: '#2563eb', fontWeight: 500 }} /> { + const { totalXp, level, xpToNextLevel, loading } = useGamification(); + const { colorScheme } = useMantineColorScheme(); + const isDark = colorScheme === 'dark'; + + if (loading) { + if (compact) { + return ( + + + + + ); + } + return ( + + + + + + ); + } + + // Calculate XP thresholds based on level + const getLevelThreshold = (lvl: number): number => { + if (lvl <= 1) return 0; + if (lvl <= 5) return (lvl - 1) * 200; + return 1000 + (lvl - 6) * 500; + }; + + const currentLevelThreshold = getLevelThreshold(level); + const nextLevelThreshold = level >= 10 ? currentLevelThreshold : getLevelThreshold(level + 1); + const xpInCurrentLevel = totalXp - currentLevelThreshold; + const xpNeededForLevel = nextLevelThreshold - currentLevelThreshold; + const percent = level >= 10 ? 100 : xpNeededForLevel > 0 ? (xpInCurrentLevel / xpNeededForLevel) * 100 : 0; + + const tooltipText = level >= 10 + ? `Level ${level} (MAX)` + : `Level ${level}: ${xpInCurrentLevel}/${xpNeededForLevel} XP (${xpToNextLevel} XP to next level)`; + + if (compact) { + const xpBarContent = ( + + + Level {level} + + = 7 ? 'yellow' : level >= 4 ? 'blue' : 'gray'} + /> + + ); + + return ( + + {xpBarContent} + + ); + } + + return ( + + + Level {level} + + = 7 ? 'yellow' : level >= 4 ? 'blue' : 'gray'} + /> + + {level >= 10 ? 'MAX' : `${xpToNextLevel} XP to next`} + + + ); +}; + diff --git a/app/web/contexts/AuthContext.tsx b/app/web/contexts/AuthContext.tsx index 096a313..d960368 100644 --- a/app/web/contexts/AuthContext.tsx +++ b/app/web/contexts/AuthContext.tsx @@ -62,6 +62,11 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { }); if (!error) { + // Trigger login gamification event (will be handled by GamificationProvider) + // We'll use a custom event that GamificationProvider listens to + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent('gamification:login')); + } router.push('/'); } diff --git a/app/web/contexts/GamificationContext.tsx b/app/web/contexts/GamificationContext.tsx new file mode 100644 index 0000000..a18cf88 --- /dev/null +++ b/app/web/contexts/GamificationContext.tsx @@ -0,0 +1,234 @@ +/** + * Gamification Context for managing XP, levels, streaks, and badges + */ +import { createContext, useContext, useState, useEffect, useCallback, useRef, ReactNode } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { useMantineColorScheme } from '@mantine/core'; +import { gamificationApi, type GamificationStateResponse, type BadgeInfo } from '@/lib/api'; +import { useAuth } from './AuthContext'; +import { BadgeEarnedModal } from '@/components/BadgeEarnedModal'; +import { LevelUpModal } from '@/components/LevelUpModal'; +import { StreakModal } from '@/components/StreakModal'; + +interface GamificationContextType { + totalXp: number; + level: number; + currentStreak: number; + xpToNextLevel: number; + badges: BadgeInfo[]; + loading: boolean; + refreshState: () => Promise; + showXpToast: (xp: number, source?: string) => void; + showStreakToast: (streak: number) => void; + showBadgeModal: (badges: BadgeInfo[]) => void; + showLevelUpToast: () => void; +} + +const GamificationContext = createContext(undefined); + +export const GamificationProvider = ({ children }: { children: ReactNode }) => { + const { user } = useAuth(); + const [state, setState] = useState({ + total_xp: 0, + level: 1, + current_streak: 0, + xp_to_next_level: 200, + badges: [], + }); + const [loading, setLoading] = useState(true); + const [xpToastQueue, setXpToastQueue] = useState>([]); + const [streakModal, setStreakModal] = useState<{ opened: boolean; streak: number }>({ opened: false, streak: 0 }); + const [badgeModal, setBadgeModal] = useState(null); + const [levelUpModal, setLevelUpModal] = useState<{ opened: boolean; level: number }>({ opened: false, level: 1 }); + const toastIdCounterRef = useRef(0); + + const showXpToast = useCallback((xp: number, source?: string) => { + const id = toastIdCounterRef.current; + toastIdCounterRef.current += 1; + setXpToastQueue((prev) => [...prev, { id, xp, source }]); + // Auto-remove after 3 seconds + setTimeout(() => { + setXpToastQueue((prev) => prev.filter((item) => item.id !== id)); + }, 3000); + }, []); + + const showStreakModal = useCallback((streak: number) => { + setStreakModal({ opened: true, streak }); + }, []); + + const showBadgeModal = useCallback((badges: BadgeInfo[]) => { + setBadgeModal(badges); + }, []); + + const showLevelUpToast = useCallback(() => { + setLevelUpModal({ opened: true, level: state.level }); + }, [state.level]); + + const refreshState = useCallback(async () => { + if (!user) { + setLoading(false); + return; + } + + setLoading(true); + try { + const data = await gamificationApi.getState(); + setState(data); + } catch (error) { + console.error('Failed to fetch gamification state:', error); + } finally { + setLoading(false); + } + }, [user]); + + useEffect(() => { + if (user) { + setLoading(true); + refreshState(); + } else { + setLoading(false); + } + }, [user, refreshState]); + + // Listen for login events + useEffect(() => { + const handleLogin = () => { + if (user) { + gamificationApi.sendEvent({ event_type: 'login' }).then((response) => { + if (response) { + if (response.xp_gained > 0) { + showXpToast(response.xp_gained, 'login'); + } + if (response.level_up) { + showLevelUpToast(); + } + if (response.new_badges.length > 0) { + showBadgeModal(response.new_badges); + } + refreshState(); + } + }); + } + }; + + window.addEventListener('gamification:login', handleLogin); + return () => { + window.removeEventListener('gamification:login', handleLogin); + }; + }, [user, showXpToast, showLevelUpToast, showBadgeModal, refreshState]); + + return ( + + {children} + {/* Toast notifications */} +
+ + {xpToastQueue.map((item) => ( + + ))} + +
+ 0} + onClose={() => setBadgeModal(null)} + /> + setLevelUpModal({ opened: false, level: levelUpModal.level })} + newLevel={levelUpModal.level} + /> + setStreakModal({ opened: false, streak: streakModal.streak })} + streak={streakModal.streak} + /> +
+ ); +}; + +export const useGamification = () => { + const context = useContext(GamificationContext); + if (context === undefined) { + throw new Error('useGamification must be used within a GamificationProvider'); + } + return context; +}; + +// Toast components with Framer Motion animations +const XpToast = ({ xp, source }: { xp: number; source?: string }) => { + const { colorScheme } = useMantineColorScheme(); + const isDark = colorScheme === 'dark'; + + const getSourceText = (source?: string): string => { + if (!source) return ''; + const sourceMap: Record = { + login: 'Daily login', + module_completed: 'Module completed', + quiz_completed: 'Quiz completed', + portfolio_position_added: 'Portfolio position added', + portfolio_position_updated: 'Portfolio position updated', + streak_bonus: 'Streak bonus', + }; + return sourceMap[source] || source; + }; + + const sourceText = getSourceText(source); + + return ( + + {sourceText && ( +
+ {sourceText}: +
+ )} +
+ +{xp} XP +
+
+ ); +}; + diff --git a/app/web/features/onboarding/constants.ts b/app/web/features/onboarding/constants.ts new file mode 100644 index 0000000..d7b91a7 --- /dev/null +++ b/app/web/features/onboarding/constants.ts @@ -0,0 +1,75 @@ +export const financialGoalsOptions = [ + "Saving for retirement", + "Buying a home", + "Starting a business", + "Paying off debt", + "General savings", +]; + +export const incomeRanges = [ + { value: "0-25k", label: "$0 - $25,000" }, + { value: "25k-50k", label: "$25,000 - $50,000" }, + { value: "50k-75k", label: "$50,000 - $75,000" }, + { value: "75k-100k", label: "$75,000 - $100,000" }, + { value: "100k-150k", label: "$100,000 - $150,000" }, + { value: "150k+", label: "$150,000+" }, +]; + +export const investmentAmounts = [ + { value: "0-1k", label: "$0 - $1,000" }, + { value: "1k-5k", label: "$1,000 - $5,000" }, + { value: "5k-10k", label: "$5,000 - $10,000" }, + { value: "10k-25k", label: "$10,000 - $25,000" }, + { value: "25k+", label: "$25,000+" }, +]; + +export const riskToleranceOptions = [ + "Conservative", + "Moderately Conservative", + "Moderate", + "Moderately Aggressive", + "Aggressive", +]; + +export const countries = [ + { value: "US", label: "United States" }, + { value: "CA", label: "Canada" }, + { value: "GB", label: "United Kingdom" }, + { value: "AU", label: "Australia" }, + { value: "DE", label: "Germany" }, + { value: "FR", label: "France" }, + { value: "IT", label: "Italy" }, + { value: "ES", label: "Spain" }, + { value: "NL", label: "Netherlands" }, + { value: "BE", label: "Belgium" }, + { value: "CH", label: "Switzerland" }, + { value: "AT", label: "Austria" }, + { value: "SE", label: "Sweden" }, + { value: "NO", label: "Norway" }, + { value: "DK", label: "Denmark" }, + { value: "FI", label: "Finland" }, + { value: "IE", label: "Ireland" }, + { value: "PT", label: "Portugal" }, + { value: "PL", label: "Poland" }, + { value: "CZ", label: "Czech Republic" }, + { value: "GR", label: "Greece" }, + { value: "JP", label: "Japan" }, + { value: "CN", label: "China" }, + { value: "IN", label: "India" }, + { value: "SG", label: "Singapore" }, + { value: "HK", label: "Hong Kong" }, + { value: "KR", label: "South Korea" }, + { value: "TW", label: "Taiwan" }, + { value: "NZ", label: "New Zealand" }, + { value: "BR", label: "Brazil" }, + { value: "MX", label: "Mexico" }, + { value: "AR", label: "Argentina" }, + { value: "ZA", label: "South Africa" }, + { value: "AE", label: "United Arab Emirates" }, + { value: "IL", label: "Israel" }, + { value: "TR", label: "Turkey" }, + { value: "RU", label: "Russia" }, +]; + +export const TOTAL_STEPS = 5; + diff --git a/app/web/features/onboarding/hooks/useOnboarding.ts b/app/web/features/onboarding/hooks/useOnboarding.ts new file mode 100644 index 0000000..68a0cde --- /dev/null +++ b/app/web/features/onboarding/hooks/useOnboarding.ts @@ -0,0 +1,73 @@ +import { useState, useCallback } from 'react'; +import { useRouter } from 'next/router'; +import { usersApi } from '@/lib/api'; +import type { OnboardingData } from '../types'; +import { TOTAL_STEPS } from '../constants'; +import { validateStep } from '../utils/validation'; + +const initialData: OnboardingData = { + financialGoals: "Saving for retirement", + investingExperience: 1, + age: 25, + annualIncome: "", + investmentAmount: "", + riskTolerance: "Moderate", + country: "US", +}; + +export const useOnboarding = () => { + const [currentStep, setCurrentStep] = useState(1); + const [loading, setLoading] = useState(false); + const [data, setData] = useState(initialData); + const router = useRouter(); + + const updateData = useCallback((updates: Partial) => { + setData(prev => ({ ...prev, ...updates })); + }, []); + + const handleComplete = useCallback(async () => { + setLoading(true); + try { + await usersApi.updateFinancialProfile(data); + setLoading(false); + router.push('/dashboard'); + } catch (error) { + console.error("Onboarding failed:", error); + setLoading(false); + alert("Failed to save onboarding data. Please try again."); + } + }, [data, router]); + + const handleNext = useCallback(() => { + if (currentStep < TOTAL_STEPS) { + setCurrentStep(prev => prev + 1); + } else { + handleComplete(); + } + }, [currentStep, handleComplete]); + + const handlePrevious = useCallback(() => { + if (currentStep > 1) { + setCurrentStep(prev => prev - 1); + } + }, [currentStep]); + + const canProceed = validateStep(currentStep, data); + const isFirstStep = currentStep === 1; + const isLastStep = currentStep === TOTAL_STEPS; + + return { + currentStep, + totalSteps: TOTAL_STEPS, + data, + loading, + updateData, + handleNext, + handlePrevious, + handleComplete, + canProceed, + isFirstStep, + isLastStep, + }; +}; + diff --git a/app/web/features/onboarding/types.ts b/app/web/features/onboarding/types.ts new file mode 100644 index 0000000..a1b4da5 --- /dev/null +++ b/app/web/features/onboarding/types.ts @@ -0,0 +1,10 @@ +export interface OnboardingData { + financialGoals: string; + investingExperience: number; + age: number; + annualIncome: string; + investmentAmount: string; + riskTolerance: string; + country: string; +} + diff --git a/app/web/features/onboarding/utils/validation.ts b/app/web/features/onboarding/utils/validation.ts new file mode 100644 index 0000000..23918b6 --- /dev/null +++ b/app/web/features/onboarding/utils/validation.ts @@ -0,0 +1,44 @@ +import type { OnboardingData } from '../types'; +import { TOTAL_STEPS } from '../constants'; + +export const validateStep = (step: number, data: OnboardingData): boolean => { + switch (step) { + case 1: + return !!data.financialGoals; + case 2: + return !!data.age && !!data.annualIncome && !!data.country; + case 3: + return !!data.investmentAmount && !!data.riskTolerance; + case 4: + return true; // Step 4 is informational + case 5: + return true; // Step 5 is review + default: + return false; + } +}; + +export const canProceedToNextStep = (currentStep: number, data: OnboardingData): boolean => { + if (currentStep >= TOTAL_STEPS) { + return false; + } + return validateStep(currentStep, data); +}; + +export const getExperienceLabel = (experience: number): string => { + switch (experience) { + case 0: + return "Not at all"; + case 1: + return "Beginner"; + case 2: + return "Intermediate"; + case 3: + return "Advanced"; + case 4: + return "Expert"; + default: + return "Beginner"; + } +}; + diff --git a/app/web/features/portfolio/hooks/usePortfolio.ts b/app/web/features/portfolio/hooks/usePortfolio.ts new file mode 100644 index 0000000..317ef0e --- /dev/null +++ b/app/web/features/portfolio/hooks/usePortfolio.ts @@ -0,0 +1,31 @@ +import { useState, useCallback } from 'react'; +import { portfolioApi } from '@/lib/api'; +import type { PortfolioHoldingsResponse } from '@/types/portfolio'; + +export const usePortfolio = () => { + const [portfolio, setPortfolio] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const loadPortfolio = useCallback(async () => { + try { + setLoading(true); + setError(null); + const data = await portfolioApi.getPortfolio(); + setPortfolio(data); + } catch (err) { + console.error('Error loading portfolio:', err); + setError(err instanceof Error ? err.message : 'Failed to load portfolio'); + } finally { + setLoading(false); + } + }, []); + + return { + portfolio, + loading, + error, + loadPortfolio, + }; +}; + diff --git a/app/web/features/portfolio/hooks/useSnapshots.ts b/app/web/features/portfolio/hooks/useSnapshots.ts new file mode 100644 index 0000000..3427d34 --- /dev/null +++ b/app/web/features/portfolio/hooks/useSnapshots.ts @@ -0,0 +1,45 @@ +import { useState, useCallback } from 'react'; +import { portfolioApi } from '@/lib/api'; +import type { SnapshotPoint } from '@/types/portfolio'; +import type { TimeRange } from '@/components/ValueChart'; +import { getDateRange } from '../utils/dateRange'; + +export const useSnapshots = (initialRange: TimeRange = '1m') => { + const [snapshots, setSnapshots] = useState([]); + const [showSkeleton, setShowSkeleton] = useState(false); + const [timeRange, setTimeRange] = useState(initialRange); + + const loadSnapshots = useCallback(async (range?: TimeRange) => { + try { + const rangeToUse = range || timeRange; + const { from, to, granularity } = getDateRange(rangeToUse); + + setShowSkeleton(false); + + // Set a timeout to show skeleton after 1 second + const skeletonTimeout = setTimeout(() => { + setShowSkeleton(true); + }, 1000); + + try { + const data = await portfolioApi.getSnapshots(from, to, granularity); + setSnapshots(data.series); + } finally { + clearTimeout(skeletonTimeout); + setShowSkeleton(false); + } + } catch (err) { + console.error('Failed to load snapshots:', err); + setShowSkeleton(false); + } + }, [timeRange]); + + return { + snapshots, + showSkeleton, + timeRange, + setTimeRange, + loadSnapshots, + }; +}; + diff --git a/app/web/features/portfolio/utils/calculations.ts b/app/web/features/portfolio/utils/calculations.ts new file mode 100644 index 0000000..596187f --- /dev/null +++ b/app/web/features/portfolio/utils/calculations.ts @@ -0,0 +1,18 @@ +interface PortfolioTotals { + totalValue: number | string; + totalCostBasis: number | string; + unrealizedPL: number | string; + dailyPL: number | string; +} + +export const calculateUnrealizedPLPercent = (totals: PortfolioTotals): number => { + if (totals.totalCostBasis === 0) return 0; + return (Number(totals.unrealizedPL) / Number(totals.totalCostBasis)) * 100; +}; + +export const calculateDailyPLPercent = (totals: PortfolioTotals): number => { + const previousValue = Number(totals.totalValue) - Number(totals.dailyPL); + if (previousValue === 0 || totals.dailyPL === 0) return 0; + return (Number(totals.dailyPL) / previousValue) * 100; +}; + diff --git a/app/web/features/portfolio/utils/dateRange.ts b/app/web/features/portfolio/utils/dateRange.ts new file mode 100644 index 0000000..4d38d05 --- /dev/null +++ b/app/web/features/portfolio/utils/dateRange.ts @@ -0,0 +1,47 @@ +import { format, subDays, startOfYear } from 'date-fns'; +import type { TimeRange } from '@/components/ValueChart'; + +export interface DateRangeResult { + from: string; + to: string; + granularity: string; +} + +export const getDateRange = (range: TimeRange): DateRangeResult => { + const to = new Date(); + let from: Date; + let granularity: string; + + switch (range) { + case '1d': + from = subDays(to, 1); + granularity = 'hourly'; + break; + case '1w': + from = subDays(to, 7); + granularity = '6hourly'; + break; + case '1m': + from = subDays(to, 30); + granularity = 'daily'; + break; + case 'ytd': + from = startOfYear(to); + granularity = 'daily'; + break; + case '1y': + from = subDays(to, 365); + granularity = 'weekly'; + break; + default: + from = subDays(to, 30); + granularity = 'daily'; + } + + return { + from: format(from, 'yyyy-MM-dd'), + to: format(to, 'yyyy-MM-dd'), + granularity, + }; +}; + diff --git a/app/web/features/portfolio/utils/formatters.ts b/app/web/features/portfolio/utils/formatters.ts new file mode 100644 index 0000000..26665d8 --- /dev/null +++ b/app/web/features/portfolio/utils/formatters.ts @@ -0,0 +1,27 @@ +export const formatCurrency = (value: number | string | null | undefined, currency: string): string => { + if (value === null || value === undefined) return '~'; + const numValue = typeof value === 'string' ? parseFloat(value) : value; + if (isNaN(numValue)) return '~'; + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: currency || 'USD', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(numValue); +}; + +export const formatPercentage = (value: number | string | null | undefined): string => { + if (value === null || value === undefined) return '~'; + const numValue = typeof value === 'string' ? parseFloat(value) : value; + if (isNaN(numValue)) return '~'; + const sign = numValue >= 0 ? '+' : ''; + return `${sign}${numValue.toFixed(2)}%`; +}; + +export const formatQuantity = (value: number | string | null | undefined): string => { + if (value === null || value === undefined) return '~'; + const numValue = typeof value === 'string' ? parseFloat(value) : value; + if (isNaN(numValue)) return '~'; + return numValue.toFixed(4); +}; + diff --git a/app/web/features/profile/components/ProfileField.tsx b/app/web/features/profile/components/ProfileField.tsx new file mode 100644 index 0000000..bf7c309 --- /dev/null +++ b/app/web/features/profile/components/ProfileField.tsx @@ -0,0 +1,38 @@ +import { Group, Text, useMantineTheme, useMantineColorScheme } from '@mantine/core'; + +interface ProfileFieldProps { + label: string; + value: string | number | undefined; + icon: React.ReactNode; +} + +export const ProfileField = ({ label, value, icon }: ProfileFieldProps) => { + const theme = useMantineTheme(); + const { colorScheme } = useMantineColorScheme(); + const isDark = colorScheme === 'dark'; + + if (value === undefined || value === null || value === '') { + return null; + } + + return ( + +
+ {icon} +
+
+ + {label} + + + {typeof value === 'number' ? value.toString() : value} + +
+
+ ); +}; + diff --git a/app/web/features/profile/constants.ts b/app/web/features/profile/constants.ts new file mode 100644 index 0000000..73d5dc5 --- /dev/null +++ b/app/web/features/profile/constants.ts @@ -0,0 +1,40 @@ +export const countries = [ + { value: "US", label: "United States" }, + { value: "CA", label: "Canada" }, + { value: "GB", label: "United Kingdom" }, + { value: "AU", label: "Australia" }, + { value: "DE", label: "Germany" }, + { value: "FR", label: "France" }, + { value: "IT", label: "Italy" }, + { value: "ES", label: "Spain" }, + { value: "NL", label: "Netherlands" }, + { value: "BE", label: "Belgium" }, + { value: "CH", label: "Switzerland" }, + { value: "AT", label: "Austria" }, + { value: "SE", label: "Sweden" }, + { value: "NO", label: "Norway" }, + { value: "DK", label: "Denmark" }, + { value: "FI", label: "Finland" }, + { value: "IE", label: "Ireland" }, + { value: "PT", label: "Portugal" }, + { value: "PL", label: "Poland" }, + { value: "CZ", label: "Czech Republic" }, + { value: "GR", label: "Greece" }, + { value: "JP", label: "Japan" }, + { value: "CN", label: "China" }, + { value: "IN", label: "India" }, + { value: "SG", label: "Singapore" }, + { value: "HK", label: "Hong Kong" }, + { value: "KR", label: "South Korea" }, + { value: "TW", label: "Taiwan" }, + { value: "NZ", label: "New Zealand" }, + { value: "BR", label: "Brazil" }, + { value: "MX", label: "Mexico" }, + { value: "AR", label: "Argentina" }, + { value: "ZA", label: "South Africa" }, + { value: "AE", label: "United Arab Emirates" }, + { value: "IL", label: "Israel" }, + { value: "TR", label: "Turkey" }, + { value: "RU", label: "Russia" }, +]; + diff --git a/app/web/features/profile/hooks/useProfile.ts b/app/web/features/profile/hooks/useProfile.ts new file mode 100644 index 0000000..b31435f --- /dev/null +++ b/app/web/features/profile/hooks/useProfile.ts @@ -0,0 +1,43 @@ +import { useState, useCallback } from 'react'; +import { usersApi } from '@/lib/api'; +import type { UserProfile } from '@/types/user'; + +export const useProfile = () => { + const [profile, setProfile] = useState(null); + const [loading, setLoading] = useState(true); + + const loadProfile = useCallback(async () => { + try { + setLoading(true); + const data = await usersApi.getFinancialProfile(); + setProfile(data); + return data; + } catch (err) { + console.error('Failed to load profile:', err); + throw err; + } finally { + setLoading(false); + } + }, []); + + const updateProfile = useCallback(async (updates: Partial) => { + if (!profile) return; + try { + const updatedProfile = { ...profile, ...updates }; + await usersApi.updateFinancialProfile(updatedProfile); + setProfile(updatedProfile); + return updatedProfile; + } catch (err) { + console.error('Failed to update profile:', err); + throw err; + } + }, [profile]); + + return { + profile, + loading, + loadProfile, + updateProfile, + }; +}; + diff --git a/app/web/features/profile/utils/formatters.ts b/app/web/features/profile/utils/formatters.ts new file mode 100644 index 0000000..24aa259 --- /dev/null +++ b/app/web/features/profile/utils/formatters.ts @@ -0,0 +1,32 @@ +export const formatExperience = (years?: number): string | undefined => { + if (years === undefined || years === null) return undefined; + if (years === 0) return 'Less than 1 year'; + if (years === 1) return '1 year'; + return `${years} years`; +}; + +export const formatIncome = (income?: string): string | undefined => { + if (!income) return undefined; + const incomeMap: Record = { + '0-25k': '$0 - $25,000', + '25k-50k': '$25,000 - $50,000', + '50k-75k': '$50,000 - $75,000', + '75k-100k': '$75,000 - $100,000', + '100k-150k': '$100,000 - $150,000', + '150k+': '$150,000+', + }; + return incomeMap[income] || income; +}; + +export const formatInvestmentAmount = (amount?: string): string | undefined => { + if (!amount) return undefined; + const amountMap: Record = { + '0-1k': '$0 - $1,000', + '1k-5k': '$1,000 - $5,000', + '5k-10k': '$5,000 - $10,000', + '10k-25k': '$10,000 - $25,000', + '25k+': '$25,000+', + }; + return amountMap[amount] || amount; +}; + diff --git a/app/web/features/profile/utils/user.ts b/app/web/features/profile/utils/user.ts new file mode 100644 index 0000000..71d72dd --- /dev/null +++ b/app/web/features/profile/utils/user.ts @@ -0,0 +1,17 @@ +import type { User } from '@supabase/supabase-js'; + +export const getUserDisplayName = (user: User | null | undefined): string => { + if (user?.user_metadata?.full_name) { + return user.user_metadata.full_name; + } + if (user?.email) { + return user.email.split('@')[0]; + } + return 'User'; +}; + +export const getUserInitial = (user: User | null | undefined): string => { + const name = getUserDisplayName(user); + return name.charAt(0).toUpperCase(); +}; + diff --git a/app/web/hooks/useGamificationEvents.ts b/app/web/hooks/useGamificationEvents.ts new file mode 100644 index 0000000..294b054 --- /dev/null +++ b/app/web/hooks/useGamificationEvents.ts @@ -0,0 +1,97 @@ +/** + * Hook for triggering gamification events + */ +import { useCallback } from 'react'; +import { gamificationApi } from '@/lib/api'; +import { useGamification } from '@/contexts/GamificationContext'; + +export const useGamificationEvents = () => { + const { showXpToast, showStreakToast, showBadgeModal, showLevelUpToast, refreshState } = useGamification(); + + const triggerEvent = useCallback( + async (event: Parameters[0]) => { + try { + const response = await gamificationApi.sendEvent(event); + + // Show XP toast with source + // If streak incremented, show streak bonus XP separately + if (response.streak_incremented && event.event_type === 'quiz_completed') { + // Calculate base quiz XP (without streak bonus) + const streakBonusXp = 2; // From XP_REWARDS + const baseXp = response.xp_gained - streakBonusXp; + if (baseXp > 0) { + showXpToast(baseXp, event.event_type); + } + showXpToast(streakBonusXp, 'streak_bonus'); + } else if (response.xp_gained > 0) { + showXpToast(response.xp_gained, event.event_type); + } + + // Show streak modal only if streak actually incremented + if (response.streak_incremented) { + showStreakToast(response.current_streak); + } + + // Show badge modal if badges earned + if (response.new_badges.length > 0) { + showBadgeModal(response.new_badges); + } + + // Show level up toast + if (response.level_up) { + showLevelUpToast(); + } + + // Refresh state + await refreshState(); + + return response; + } catch (error) { + console.error('Failed to send gamification event:', error); + // Don't throw - gamification failures shouldn't break the app + return null; + } + }, + [showXpToast, showStreakToast, showBadgeModal, showLevelUpToast, refreshState] + ); + + return { + triggerEvent, + triggerLogin: useCallback(() => triggerEvent({ event_type: 'login' }), [triggerEvent]), + triggerModuleCompleted: useCallback( + (moduleId: string, isFirstTime: boolean) => + triggerEvent({ + event_type: 'module_completed', + module_id: moduleId, + is_first_time_for_module: isFirstTime, + }), + [triggerEvent] + ), + triggerQuizCompleted: useCallback( + (score: number, completedAt?: string) => + triggerEvent({ + event_type: 'quiz_completed', + quiz_score: score, + quiz_completed_at: completedAt || new Date().toISOString(), + }), + [triggerEvent] + ), + triggerPortfolioPositionAdded: useCallback( + (positionId?: string) => + triggerEvent({ + event_type: 'portfolio_position_added', + portfolio_position_id: positionId, + }), + [triggerEvent] + ), + triggerPortfolioPositionUpdated: useCallback( + (positionId?: string) => + triggerEvent({ + event_type: 'portfolio_position_updated', + portfolio_position_id: positionId, + }), + [triggerEvent] + ), + }; +}; + diff --git a/app/web/lib/api.ts b/app/web/lib/api.ts index 1eb46f5..2c0b42b 100644 --- a/app/web/lib/api.ts +++ b/app/web/lib/api.ts @@ -9,6 +9,7 @@ import type { SnapshotsResponse, } from '@/types/portfolio'; import type { + UserProfile, UpdateProfileRequest, UpdateProfileResponse, } from '@/types/user'; @@ -116,6 +117,20 @@ export const portfolioApi = { * Users API client */ export const usersApi = { + /** + * Check if user has completed onboarding + */ + getOnboardingStatus: async (): Promise<{ completed: boolean }> => { + return apiRequest<{ completed: boolean }>('/api/v1/users/onboarding-status'); + }, + + /** + * Get user's financial profile + */ + getFinancialProfile: async (): Promise => { + return apiRequest('/api/v1/users/financial-profile'); + }, + /** * Update user's financial profile */ @@ -156,3 +171,78 @@ export const modulesApi = { }, }; +/** + * Gamification types + */ +export interface GamificationEventRequest { + event_type: 'login' | 'module_completed' | 'quiz_completed' | 'portfolio_position_added' | 'portfolio_position_updated'; + module_id?: string; + quiz_score?: number; + quiz_completed_at?: string; + portfolio_position_id?: string; + is_first_time_for_module?: boolean; +} + +export interface BadgeInfo { + code: string; + name: string; + description: string; +} + +export interface GamificationEventResponse { + total_xp: number; + level: number; + current_streak: number; + xp_gained: number; + level_up: boolean; + streak_incremented: boolean; + new_badges: BadgeInfo[]; + xp_to_next_level: number; +} + +export interface GamificationStateResponse { + total_xp: number; + level: number; + current_streak: number; + xp_to_next_level: number; + badges: BadgeInfo[]; +} + +export interface BadgeDefinitionResponse { + code: string; + name: string; + description: string; + category: string; + is_active: boolean; + earned: boolean; +} + +/** + * Gamification API client + */ +export const gamificationApi = { + /** + * Send a gamification event + */ + sendEvent: async (event: GamificationEventRequest): Promise => { + return apiRequest('/api/gamification/event', { + method: 'POST', + body: JSON.stringify(event), + }); + }, + + /** + * Get current gamification state + */ + getState: async (): Promise => { + return apiRequest('/api/gamification/me'); + }, + + /** + * Get all badges + */ + getBadges: async (): Promise => { + return apiRequest('/api/gamification/badges'); + }, +}; + diff --git a/app/web/package.json b/app/web/package.json index f3d53bb..b22c8a1 100644 --- a/app/web/package.json +++ b/app/web/package.json @@ -23,6 +23,7 @@ "@supabase/supabase-js": "^2.47.16", "@tabler/icons-react": "^3.35.0", "date-fns": "^3.6.0", + "framer-motion": "^12.23.24", "next": "15.5.4", "react": "19.1.0", "react-dom": "19.1.0", diff --git a/app/web/pages/_app.tsx b/app/web/pages/_app.tsx index 4c86e32..1e2bf76 100644 --- a/app/web/pages/_app.tsx +++ b/app/web/pages/_app.tsx @@ -4,6 +4,7 @@ import '../styles/globals.css'; import type { AppProps } from 'next/app'; import { createTheme, MantineProvider } from '@mantine/core'; import { AuthProvider } from '@/contexts/AuthContext'; +import { GamificationProvider } from '@/contexts/GamificationContext'; const theme = createTheme({ /** Put your mantine theme override here */ @@ -16,7 +17,9 @@ export default function App({ Component, pageProps }: AppProps) { return ( - + + + ); diff --git a/app/web/pages/dashboard.tsx b/app/web/pages/dashboard.tsx index 2148582..daa7df1 100644 --- a/app/web/pages/dashboard.tsx +++ b/app/web/pages/dashboard.tsx @@ -9,21 +9,50 @@ import { Stack, Paper, AppShell, + Skeleton, + useMantineColorScheme, } from '@mantine/core'; import ProtectedRoute from '@/components/ProtectedRoute'; import { AppNav } from '@/components/AppNav'; import { ValueChart, type TimeRange } from '@/components/ValueChart'; import { SuggestionsWidget } from '@/components/SuggestionsWidget'; +import { GamificationEngagement } from '@/components/GamificationEngagement'; import { portfolioApi, usersApi } from '@/lib/api'; import type { PortfolioHoldingsResponse, SnapshotPoint } from '@/types/portfolio'; import type { Suggestion } from '@/types/learning'; import { format, subDays, startOfYear } from 'date-fns'; +/** + * Dashboard skeleton component for initial loading state + */ +const DashboardSkeleton = () => ( + + {/* Header */} + + + {/* My Investments Section */} +
+ + + + +
+ + {/* AI Suggestions Widget Skeleton */} + + + + +
+); + const DashboardPage = () => { + const { colorScheme } = useMantineColorScheme(); + const isDark = colorScheme === 'dark'; const [portfolio, setPortfolio] = useState(null); const [snapshots, setSnapshots] = useState([]); const [suggestions, setSuggestions] = useState([]); - const [, setLoading] = useState(true); + const [loading, setLoading] = useState(true); const [loadingSuggestions, setLoadingSuggestions] = useState(true); const [timeRange, setTimeRange] = useState('1m'); const [showSnapshotsSkeleton, setShowSnapshotsSkeleton] = useState(false); @@ -159,6 +188,21 @@ const DashboardPage = () => { return `${sign}${numValue.toFixed(2)}%`; }; + if (loading && !portfolio) { + return ( + + + + + + + + + + + ); + } + return ( @@ -172,6 +216,12 @@ const DashboardPage = () => { {/* Header */} Dashboard + {/* Gamification Engagement */} + + {/* My Investments Section */}
My investments @@ -183,12 +233,18 @@ const DashboardPage = () => { top: 20, left: 20, zIndex: 10, - backgroundColor: 'rgba(255, 255, 255, 0.95)', + backgroundColor: isDark ? 'rgba(37, 38, 43, 0.95)' : 'rgba(255, 255, 255, 0.95)', padding: '12px 16px', borderRadius: '8px', - boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)' + boxShadow: isDark ? '0 2px 8px rgba(0, 0, 0, 0.3)' : '0 2px 8px rgba(0, 0, 0, 0.1)', + backdropFilter: 'blur(10px)', + border: isDark ? '1px solid rgba(255, 255, 255, 0.1)' : 'none' }}> -
+
{formatCurrency(totals.totalValue, baseCurrency)}
{ const { colorScheme, setColorScheme } = useMantineColorScheme(); @@ -45,10 +46,25 @@ const Home = () => { const router = useRouter(); useEffect(() => { - // If user is authenticated, redirect to onboarding/dashboard - if (!loading && user) { - router.push('/onboarding'); - } + const checkOnboardingAndRedirect = async () => { + // If user is authenticated, check onboarding status and redirect accordingly + if (!loading && user) { + try { + const { completed } = await usersApi.getOnboardingStatus(); + if (completed) { + router.push('/dashboard'); + } else { + router.push('/onboarding'); + } + } catch (error) { + // If there's an error checking onboarding status, default to onboarding + console.error("Error checking onboarding status:", error); + router.push('/onboarding'); + } + } + }; + + checkOnboardingAndRedirect(); }, [user, loading, router]); return ( diff --git a/app/web/pages/learn.tsx b/app/web/pages/learn.tsx index d757f60..c659ded 100644 --- a/app/web/pages/learn.tsx +++ b/app/web/pages/learn.tsx @@ -5,27 +5,65 @@ import { Title, Text, Stack, - SimpleGrid, Card, - Badge, - Button, - Group, - ThemeIcon, - Loader, - Center, AppShell, + Skeleton, + Box, } from "@mantine/core"; -import { IconBook, IconChartBar, IconAlertTriangle, IconArrowRight } from "@tabler/icons-react"; import { AppNav } from "@/components/AppNav"; import ProtectedRoute from "@/components/ProtectedRoute"; import { usersApi } from "@/lib/api"; -import { useRouter } from "next/router"; import type { Suggestion } from "@/types/learning"; +import { LearningPathway } from "@/components/LearningPathway"; + +/** + * Learning page skeleton component for loading state + */ +const LearnSkeleton = () => ( + +
+ + +
+ + + + {[1, 2, 3, 4, 5].map((i, index) => ( + + {index < 4 && ( + + )} + + + + + + + + + {index < 4 && } + + ))} + + +
+); const Learn = () => { const [suggestions, setSuggestions] = useState([]); const [loading, setLoading] = useState(true); - const router = useRouter(); useEffect(() => { const fetchSuggestions = async () => { @@ -42,35 +80,6 @@ const Learn = () => { fetchSuggestions(); }, []); - const getIcon = (type: string | undefined) => { - switch (type) { - case "investment": - return ; - case "warning": - return ; - default: - return ; - } - }; - - const getColor = (type: string | undefined) => { - switch (type) { - case "investment": - return "blue"; - case "warning": - return "orange"; - default: - return "teal"; - } - }; - - const getConfidenceLabel = (confidence: number | null) => { - if (!confidence) return { label: 'Low Match', color: 'gray' }; - if (confidence >= 0.8) return { label: 'High Match', color: 'green' }; - if (confidence >= 0.5) return { label: 'Medium Match', color: 'yellow' }; - return { label: 'Low Match', color: 'gray' }; - }; - return ( @@ -79,7 +88,7 @@ const Learn = () => { - +
Your Learning Path @@ -89,9 +98,7 @@ const Learn = () => {
{loading ? ( -
- -
+ ) : suggestions.length === 0 ? ( @@ -99,56 +106,7 @@ const Learn = () => { ) : ( - - {suggestions.map((suggestion) => ( - - - - - - {getIcon(suggestion.metadata?.type)} - - - {suggestion.metadata?.topic || "General"} - - - {suggestion.confidence && ( - - {getConfidenceLabel(suggestion.confidence).label} - - )} - - - - - - {suggestion.reason} - - - - - - ))} - + )}
diff --git a/app/web/pages/modules/[id].tsx b/app/web/pages/modules/[id].tsx index 21e029c..ab53092 100644 --- a/app/web/pages/modules/[id].tsx +++ b/app/web/pages/modules/[id].tsx @@ -6,6 +6,7 @@ import { AppNav } from '../../components/AppNav'; import ProtectedRoute from '../../components/ProtectedRoute'; import { ModuleViewer, ModuleContent } from '../../components/ModuleViewer'; import { modulesApi } from '../../lib/api'; +import { useGamificationEvents } from '../../hooks/useGamificationEvents'; export default function ModulePage() { const router = useRouter(); @@ -13,6 +14,8 @@ export default function ModulePage() { const [module, setModule] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const { triggerModuleCompleted, triggerQuizCompleted } = useGamificationEvents(); + const [completedModules, setCompletedModules] = useState>(new Set()); useEffect(() => { if (!id) return; @@ -41,9 +44,20 @@ export default function ModulePage() { const percentage = (score / total) * 100; const passed = percentage >= 70; + const moduleId = id as string; try { - await modulesApi.submitAttempt(id as string, score, total, passed); + await modulesApi.submitAttempt(moduleId, score, total, passed); + + // Only trigger gamification events if quiz was passed + if (passed) { + // Trigger gamification events + const isFirstTime = !completedModules.has(moduleId); + await triggerModuleCompleted(moduleId, isFirstTime); + setCompletedModules((prev) => new Set(prev).add(moduleId)); + + await triggerQuizCompleted(percentage); + } } catch (err) { console.error("Failed to submit attempt:", err); } diff --git a/app/web/pages/onboarding.tsx b/app/web/pages/onboarding.tsx index 0e76c84..045e0e1 100644 --- a/app/web/pages/onboarding.tsx +++ b/app/web/pages/onboarding.tsx @@ -27,99 +27,53 @@ import ProtectedRoute from "@/components/ProtectedRoute"; import { useAuth } from "@/contexts/AuthContext"; import { useRouter } from "next/router"; import { usersApi } from "@/lib/api"; - -interface OnboardingData { - financialGoals: string; - investingExperience: number; - age: number; - annualIncome: string; - investmentAmount: string; - riskTolerance: string; -} - -const financialGoalsOptions = [ - "Saving for retirement", - "Buying a home", - "Starting a business", - "Paying off debt", - "General savings", -]; - -const incomeRanges = [ - { value: "0-25k", label: "$0 - $25,000" }, - { value: "25k-50k", label: "$25,000 - $50,000" }, - { value: "50k-75k", label: "$50,000 - $75,000" }, - { value: "75k-100k", label: "$75,000 - $100,000" }, - { value: "100k-150k", label: "$100,000 - $150,000" }, - { value: "150k+", label: "$150,000+" }, -]; - -const investmentAmounts = [ - { value: "0-1k", label: "$0 - $1,000" }, - { value: "1k-5k", label: "$1,000 - $5,000" }, - { value: "5k-10k", label: "$5,000 - $10,000" }, - { value: "10k-25k", label: "$10,000 - $25,000" }, - { value: "25k+", label: "$25,000+" }, -]; - -const riskToleranceOptions = [ - "Conservative", - "Moderately Conservative", - "Moderate", - "Moderately Aggressive", - "Aggressive", -]; +import { useOnboarding } from "@/features/onboarding/hooks/useOnboarding"; +import { + financialGoalsOptions, + incomeRanges, + investmentAmounts, + riskToleranceOptions, + countries, +} from "@/features/onboarding/constants"; +import { getExperienceLabel } from "@/features/onboarding/utils/validation"; const Onboarding = () => { - const [currentStep, setCurrentStep] = useState(1); - const [loading, setLoading] = useState(false); const [mounted, setMounted] = useState(false); const { colorScheme } = useMantineColorScheme(); - const { user, signOut } = useAuth(); + const { user, signOut, loading: authLoading } = useAuth(); const router = useRouter(); + const { + currentStep, + totalSteps, + data, + loading, + updateData, + handleNext, + handlePrevious, + canProceed, + isFirstStep, + } = useOnboarding(); useEffect(() => { setMounted(true); }, []); - const [data, setData] = useState({ - financialGoals: "Saving for retirement", - investingExperience: 1, - age: 25, - annualIncome: "", - investmentAmount: "", - riskTolerance: "Moderate", - }); - - const totalSteps = 5; - - const handleNext = () => { - if (currentStep < totalSteps) { - setCurrentStep(currentStep + 1); - } else { - handleComplete(); - } - }; - - const handlePrevious = () => { - if (currentStep > 1) { - setCurrentStep(currentStep - 1); - } - }; - - const handleComplete = async () => { - setLoading(true); - - try { - await usersApi.updateFinancialProfile(data); - setLoading(false); - router.push('/dashboard'); - } catch (error) { - console.error("Onboarding failed:", error); - setLoading(false); - alert("Failed to save onboarding data. Please try again."); - } - }; + useEffect(() => { + const checkOnboardingStatus = async () => { + if (!authLoading && user) { + try { + const { completed } = await usersApi.getOnboardingStatus(); + if (completed) { + router.push('/dashboard'); + } + } catch (error) { + console.error("Error checking onboarding status:", error); + } + } + }; + + checkOnboardingStatus(); + }, [user, authLoading, router]); const renderStep = () => { switch (currentStep) { @@ -131,7 +85,7 @@ const Onboarding = () => { setData({ ...data, financialGoals: value })} + onChange={(value) => updateData({ financialGoals: value })} > {financialGoalsOptions.map((goal) => ( @@ -154,7 +108,7 @@ const Onboarding = () => { setData({ ...data, investingExperience: value })} + onChange={(value) => updateData({ investingExperience: value })} min={0} max={4} step={1} @@ -183,7 +137,7 @@ const Onboarding = () => { label="What's your age?" placeholder="Enter your age" value={data.age} - onChange={(value) => setData({ ...data, age: Number(value) || 25 })} + onChange={(value) => updateData({ age: Number(value) || 25 })} min={18} max={100} size="md" @@ -194,7 +148,17 @@ const Onboarding = () => { placeholder="Select your income range" data={incomeRanges} value={data.annualIncome} - onChange={(value) => setData({ ...data, annualIncome: value || "" })} + onChange={(value) => updateData({ annualIncome: value || "" })} + size="md" + searchable + /> + + setCountryValue(value || "US")} + searchable + style={{ flex: 1 }} + /> + + + +
+ + ) : ( + setEditingCountry(true)} + > +
+ +
+
+ + Country + + + {profile?.country ? countries.find(c => c.value === profile.country)?.label || profile.country : "Not specified"} + +
+
+ )} + + + {/* Badges Section */} + + + + + {/* Financial Profile Card */} + + + Financial Profile + + {loading ? ( + + + + + + ) : ( + + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + {(!profile || Object.values(profile).every(v => v === undefined || v === null || v === '')) && ( + + No financial profile information available. Complete onboarding to add your profile details. + + )} + + )} + + + + + + + ); +}; + +export default ProfilePage; + diff --git a/app/web/pnpm-lock.yaml b/app/web/pnpm-lock.yaml index 09bc6ed..1db0f09 100644 --- a/app/web/pnpm-lock.yaml +++ b/app/web/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: date-fns: specifier: ^3.6.0 version: 3.6.0 + framer-motion: + specifier: ^12.23.24 + version: 12.23.24(react-dom@19.1.0(react@19.1.0))(react@19.1.0) next: specifier: 15.5.4 version: 15.5.4(@babel/core@7.28.5)(@playwright/test@1.56.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -1842,6 +1845,20 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + framer-motion@12.23.24: + resolution: {integrity: sha512-HMi5HRoRCTou+3fb3h9oTLyJGBxHfW+HnNE25tAXOvVx/IvwMHK0cx7IR4a2ZU6sh3IX1Z+4ts32PcYBOqka8w==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + fsevents@2.3.2: resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -2381,6 +2398,12 @@ packages: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} + motion-dom@12.23.23: + resolution: {integrity: sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==} + + motion-utils@12.23.6: + resolution: {integrity: sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -5092,6 +5115,15 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + framer-motion@12.23.24(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + motion-dom: 12.23.23 + motion-utils: 12.23.6 + tslib: 2.8.1 + optionalDependencies: + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + fsevents@2.3.2: optional: true @@ -5795,6 +5827,12 @@ snapshots: minipass@7.1.2: {} + motion-dom@12.23.23: + dependencies: + motion-utils: 12.23.6 + + motion-utils@12.23.6: {} + ms@2.1.3: {} nanoid@3.3.11: {} diff --git a/app/web/src/test/components/AddPositionDialog.test.tsx b/app/web/src/test/components/AddPositionDialog.test.tsx new file mode 100644 index 0000000..6f71308 --- /dev/null +++ b/app/web/src/test/components/AddPositionDialog.test.tsx @@ -0,0 +1,149 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '../test-utils' +import userEvent from '@testing-library/user-event' +import { AddPositionDialog } from '@/components/AddPositionDialog' +import { portfolioApi } from '@/lib/api' +import { useGamificationEvents } from '@/hooks/useGamificationEvents' + +vi.mock('@/lib/api', () => ({ + portfolioApi: { + addPosition: vi.fn(), + }, +})) + +vi.mock('@/hooks/useGamificationEvents', () => ({ + useGamificationEvents: vi.fn(), +})) + +describe('AddPositionDialog', () => { + const mockOnClose = vi.fn() + const mockOnSuccess = vi.fn() + const mockTriggerPortfolioPositionAdded = vi.fn().mockResolvedValue(undefined) + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(useGamificationEvents).mockReturnValue({ + triggerEvent: vi.fn(), + triggerLogin: vi.fn(), + triggerModuleCompleted: vi.fn(), + triggerQuizCompleted: vi.fn(), + triggerPortfolioPositionAdded: mockTriggerPortfolioPositionAdded, + triggerPortfolioPositionUpdated: vi.fn(), + }) + }) + + it('renders dialog when opened', () => { + render() + // Use getAllByText since "Add Position" appears in both title and button + expect(screen.getAllByText('Add Position').length).toBeGreaterThan(0) + expect(screen.getByLabelText(/ticker symbol/i)).toBeInTheDocument() + expect(screen.getByLabelText(/quantity/i)).toBeInTheDocument() + expect(screen.getByLabelText(/average cost/i)).toBeInTheDocument() + }) + + it('does not render when closed', () => { + render() + expect(screen.queryByText('Add Position')).not.toBeInTheDocument() + }) + + it('validates required fields', async () => { + const user = userEvent.setup() + render() + const submitButton = screen.getByRole('button', { name: /add position/i }) + await user.click(submitButton) + await waitFor(() => { + expect(screen.getByText(/ticker symbol is required/i)).toBeInTheDocument() + }) + }) + + it('validates quantity > 0', async () => { + const user = userEvent.setup() + render() + const symbolInput = screen.getByLabelText(/ticker symbol/i) + await user.type(symbolInput, 'AAPL') + const submitButton = screen.getByRole('button', { name: /add position/i }) + await user.click(submitButton) + await waitFor(() => { + expect(screen.getByText(/quantity must be greater than 0/i)).toBeInTheDocument() + }) + }) + + it('validates average cost > 0', async () => { + const user = userEvent.setup() + render() + const symbolInput = screen.getByLabelText(/ticker symbol/i) + const quantityInput = screen.getByLabelText(/quantity/i) + await user.type(symbolInput, 'AAPL') + await user.clear(quantityInput) + await user.type(quantityInput, '10') + const submitButton = screen.getByRole('button', { name: /add position/i }) + await user.click(submitButton) + await waitFor(() => { + expect(screen.getByText(/average cost must be greater than 0/i)).toBeInTheDocument() + }) + }) + + it('submits form successfully', async () => { + const user = userEvent.setup() + vi.mocked(portfolioApi.addPosition).mockResolvedValue({ + status: 'success', + portfolioId: '123', + transactionIds: ['tx-1'], + }) + render() + const symbolInput = screen.getByLabelText(/ticker symbol/i) + const quantityInput = screen.getByLabelText(/quantity/i) + const avgCostInput = screen.getByLabelText(/average cost/i) + await user.type(symbolInput, 'AAPL') + await user.clear(quantityInput) + await user.type(quantityInput, '10') + await user.clear(avgCostInput) + await user.type(avgCostInput, '180') + const submitButton = screen.getByRole('button', { name: /add position/i }) + await user.click(submitButton) + await waitFor(() => { + expect(portfolioApi.addPosition).toHaveBeenCalledWith({ + symbol: 'AAPL', + quantity: 10, + avgCost: 180, + executedAt: undefined, + }) + }) + await waitFor(() => { + expect(mockTriggerPortfolioPositionAdded).toHaveBeenCalled() + expect(mockOnSuccess).toHaveBeenCalled() + expect(mockOnClose).toHaveBeenCalled() + }) + }) + + it('handles API errors', async () => { + const user = userEvent.setup() + vi.mocked(portfolioApi.addPosition).mockRejectedValue(new Error('API Error')) + render() + const symbolInput = screen.getByLabelText(/ticker symbol/i) + const quantityInput = screen.getByLabelText(/quantity/i) + const avgCostInput = screen.getByLabelText(/average cost/i) + await user.type(symbolInput, 'AAPL') + await user.clear(quantityInput) + await user.type(quantityInput, '10') + await user.clear(avgCostInput) + await user.type(avgCostInput, '180') + const submitButton = screen.getByRole('button', { name: /add position/i }) + await user.click(submitButton) + await waitFor(() => { + expect(screen.getByText(/API Error/i)).toBeInTheDocument() + }) + }) + + it('resets form on close', async () => { + const user = userEvent.setup() + render() + const symbolInput = screen.getByLabelText(/ticker symbol/i) as HTMLInputElement + await user.type(symbolInput, 'AAPL') + const cancelButton = screen.getByRole('button', { name: /cancel/i }) + await user.click(cancelButton) + expect(mockOnClose).toHaveBeenCalled() + }) +}) + + diff --git a/app/web/src/test/components/AllocationChart.test.tsx b/app/web/src/test/components/AllocationChart.test.tsx new file mode 100644 index 0000000..0cdeff9 --- /dev/null +++ b/app/web/src/test/components/AllocationChart.test.tsx @@ -0,0 +1,85 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '../test-utils' +import { AllocationChart } from '@/components/AllocationChart' + +describe('AllocationChart', () => { + it('renders chart with data', () => { + const data = { + AAPL: 0.4, + GOOGL: 0.3, + MSFT: 0.3, + } + render() + expect(screen.getByText('Portfolio Allocation')).toBeInTheDocument() + expect(screen.getByText('AAPL')).toBeInTheDocument() + expect(screen.getByText('GOOGL')).toBeInTheDocument() + expect(screen.getByText('MSFT')).toBeInTheDocument() + }) + + it('shows message when data is empty', () => { + render() + expect(screen.getByText('No allocation data available')).toBeInTheDocument() + }) + + it('converts string values to numbers', () => { + const data: Record = { + AAPL: 0.5, + GOOGL: 0.5, + } + render() + expect(screen.getByText('AAPL')).toBeInTheDocument() + expect(screen.getByText('GOOGL')).toBeInTheDocument() + }) + + it('displays percentages correctly', () => { + const data = { + AAPL: 0.4, + GOOGL: 0.6, + } + render() + expect(screen.getByText('40%')).toBeInTheDocument() + expect(screen.getByText('60%')).toBeInTheDocument() + }) + + it('uses custom colors when provided', () => { + const data = { + AAPL: 0.5, + GOOGL: 0.5, + } + const customColors = ['#FF0000', '#00FF00'] + render() + expect(screen.getByText('AAPL')).toBeInTheDocument() + }) + + it('handles large number of allocations', () => { + const data: Record = {} + for (let i = 0; i < 15; i++) { + data[`STOCK${i}`] = 1 / 15 + } + render() + expect(screen.getByText('STOCK0')).toBeInTheDocument() + expect(screen.getByText('STOCK14')).toBeInTheDocument() + }) + + it('handles zero values correctly', () => { + const data = { + AAPL: 0, + GOOGL: 1, + } + render() + expect(screen.getByText('AAPL')).toBeInTheDocument() + expect(screen.getByText('GOOGL')).toBeInTheDocument() + }) + + it('handles very small values', () => { + const data = { + AAPL: 0.0001, + GOOGL: 0.9999, + } + render() + expect(screen.getByText('AAPL')).toBeInTheDocument() + expect(screen.getByText('GOOGL')).toBeInTheDocument() + }) +}) + + diff --git a/app/web/src/test/components/AppNav.test.tsx b/app/web/src/test/components/AppNav.test.tsx new file mode 100644 index 0000000..3301fd3 --- /dev/null +++ b/app/web/src/test/components/AppNav.test.tsx @@ -0,0 +1,163 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '../test-utils' +import userEvent from '@testing-library/user-event' +import { AppNav } from '@/components/AppNav' +import { mockRouter } from '../test-utils' +import { AppShell } from '@mantine/core' + +const mockAuthContext = { + user: { id: '1', email: 'test@example.com' }, + session: null, + loading: false, + signUp: vi.fn(), + signIn: vi.fn(), + signInWithGoogle: vi.fn(), + signOut: vi.fn(), +} + +const mockGamificationContext = { + totalXp: 500, + level: 3, + currentStreak: 5, + xpToNextLevel: 100, + badges: [], + loading: false, + refreshState: vi.fn(), + showXpToast: vi.fn(), + showStreakToast: vi.fn(), + showBadgeModal: vi.fn(), + showLevelUpToast: vi.fn(), +} + +vi.mock('@/contexts/AuthContext', () => ({ + useAuth: () => mockAuthContext, +})) + +vi.mock('@/contexts/GamificationContext', () => ({ + useGamification: () => mockGamificationContext, +})) + +describe('AppNav', () => { + beforeEach(() => { + vi.clearAllMocks() + mockRouter.pathname = '/dashboard' + }) + + it('renders logo and navigation links', () => { + render( + + + + ) + expect(screen.getByText('FinQuest')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /dashboard/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /portfolio/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /learn/i })).toBeInTheDocument() + }) + + it('navigates to dashboard when clicked', async () => { + const user = userEvent.setup() + render( + + + + ) + const dashboardButton = screen.getByRole('button', { name: /dashboard/i }) + await user.click(dashboardButton) + expect(mockRouter.push).toHaveBeenCalledWith('/dashboard') + }) + + it('navigates to portfolio when clicked', async () => { + const user = userEvent.setup() + render( + + + + ) + const portfolioButton = screen.getByRole('button', { name: /portfolio/i }) + await user.click(portfolioButton) + expect(mockRouter.push).toHaveBeenCalledWith('/portfolio') + }) + + it('navigates to learn when clicked', async () => { + const user = userEvent.setup() + render( + + + + ) + const learnButton = screen.getByRole('button', { name: /learn/i }) + await user.click(learnButton) + expect(mockRouter.push).toHaveBeenCalledWith('/learn') + }) + + it('shows user email in menu', async () => { + const user = userEvent.setup() + render( + + + + ) + const avatar = screen.getByText('T') + await user.click(avatar) + await waitFor(() => { + expect(screen.getByText('test@example.com')).toBeInTheDocument() + }) + }) + + it('navigates to profile when profile menu item is clicked', async () => { + const user = userEvent.setup() + render( + + + + ) + const avatar = screen.getByText('T') + await user.click(avatar) + await waitFor(() => { + expect(screen.getByText(/profile/i)).toBeInTheDocument() + }) + const profileItem = screen.getByText(/profile/i) + await user.click(profileItem) + expect(mockRouter.push).toHaveBeenCalledWith('/profile') + }) + + it('calls signOut when logout is clicked', async () => { + const user = userEvent.setup() + render( + + + + ) + const avatar = screen.getByText('T') + await user.click(avatar) + await waitFor(() => { + expect(screen.getByText(/logout/i)).toBeInTheDocument() + }) + const logoutItem = screen.getByText(/logout/i) + await user.click(logoutItem) + expect(mockAuthContext.signOut).toHaveBeenCalled() + }) + + it('displays level badge', () => { + mockGamificationContext.level = 5 + render( + + + + ) + // Level appears in both streak indicator and badge, so use getAllByText + expect(screen.getAllByText('5').length).toBeGreaterThan(0) + }) + + it('shows loading skeleton when gamification is loading', () => { + mockGamificationContext.loading = true + render( + + + + ) + expect(document.querySelectorAll('.mantine-Skeleton-root').length).toBeGreaterThan(0) + }) +}) + diff --git a/app/web/src/test/components/BadgeEarnedModal.test.tsx b/app/web/src/test/components/BadgeEarnedModal.test.tsx new file mode 100644 index 0000000..d133ee5 --- /dev/null +++ b/app/web/src/test/components/BadgeEarnedModal.test.tsx @@ -0,0 +1,69 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '../test-utils' +import userEvent from '@testing-library/user-event' +import { BadgeEarnedModal } from '@/components/BadgeEarnedModal' +import type { BadgeInfo } from '@/lib/api' + +const mockBadges: BadgeInfo[] = [ + { + code: 'first_quiz', + name: 'First Quiz', + description: 'Complete your first quiz', + }, + { + code: 'week_streak', + name: 'Week Warrior', + description: 'Maintain a 7-day streak', + }, +] + +describe('BadgeEarnedModal', () => { + it('returns null when badges array is empty', () => { + const { container } = render( + + ) + // Component returns null, but container may have style tags from Mantine + const textContent = container.textContent || '' + expect(textContent).not.toContain('Badge Earned') + expect(textContent).not.toContain('🏆') + }) + + it('renders modal when opened with badges', () => { + render( + + ) + expect(screen.getByText('🏆 Badge Earned!')).toBeInTheDocument() + expect(screen.getByText('First Quiz')).toBeInTheDocument() + expect(screen.getByText('Complete your first quiz')).toBeInTheDocument() + expect(screen.getByText('Week Warrior')).toBeInTheDocument() + expect(screen.getByText('Maintain a 7-day streak')).toBeInTheDocument() + }) + + it('calls onClose when close button is clicked', async () => { + const user = userEvent.setup() + const onClose = vi.fn() + render( + + ) + const button = screen.getByRole('button', { name: /awesome/i }) + await user.click(button) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('does not render modal when opened is false', () => { + render( + + ) + expect(screen.queryByText('🏆 Badge Earned!')).not.toBeInTheDocument() + }) + + it('renders multiple badges correctly', () => { + render( + + ) + const badges = screen.getAllByText(/🏅/) + expect(badges.length).toBeGreaterThan(0) + }) +}) + + diff --git a/app/web/src/test/components/BadgesGrid.test.tsx b/app/web/src/test/components/BadgesGrid.test.tsx new file mode 100644 index 0000000..eaad01e --- /dev/null +++ b/app/web/src/test/components/BadgesGrid.test.tsx @@ -0,0 +1,118 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '../test-utils' +import { BadgesGrid } from '@/components/BadgesGrid' +import { gamificationApi } from '@/lib/api' +import type { BadgeDefinitionResponse } from '@/lib/api' + +vi.mock('@/lib/api', () => ({ + gamificationApi: { + getBadges: vi.fn(), + }, +})) + +const mockBadges: BadgeDefinitionResponse[] = [ + { + code: 'first_quiz', + name: 'First Quiz', + description: 'Complete your first quiz', + category: 'learning', + is_active: true, + earned: true, + }, + { + code: 'week_streak', + name: 'Week Warrior', + description: 'Maintain a 7-day streak', + category: 'streak', + is_active: true, + earned: true, + }, + { + code: 'month_streak', + name: 'Month Master', + description: 'Maintain a 30-day streak', + category: 'streak', + is_active: true, + earned: false, + }, + { + code: 'inactive_badge', + name: 'Inactive Badge', + description: 'This badge is inactive', + category: 'other', + is_active: false, + earned: false, + }, +] + +describe('BadgesGrid', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('shows loading state initially', () => { + vi.mocked(gamificationApi.getBadges).mockImplementation( + () => new Promise(() => {}) // Never resolves + ) + render() + expect(screen.getByText('Loading badges...')).toBeInTheDocument() + }) + + it('renders earned badges', async () => { + vi.mocked(gamificationApi.getBadges).mockResolvedValue(mockBadges) + render() + await waitFor(() => { + expect(screen.getByText('Your Badges (2)')).toBeInTheDocument() + }) + expect(screen.getByText('First Quiz')).toBeInTheDocument() + expect(screen.getByText('Week Warrior')).toBeInTheDocument() + expect(screen.getAllByText('Earned')).toHaveLength(2) + }) + + it('renders available badges section when there are unearned badges', async () => { + vi.mocked(gamificationApi.getBadges).mockResolvedValue(mockBadges) + render() + await waitFor(() => { + expect(screen.getByText('Available Badges')).toBeInTheDocument() + }) + expect(screen.getByText('Month Master')).toBeInTheDocument() + expect(screen.getByText('Locked')).toBeInTheDocument() + }) + + it('does not render inactive badges', async () => { + vi.mocked(gamificationApi.getBadges).mockResolvedValue(mockBadges) + render() + await waitFor(() => { + expect(screen.queryByText('Inactive Badge')).not.toBeInTheDocument() + }) + }) + + it('shows message when no badges earned', async () => { + vi.mocked(gamificationApi.getBadges).mockResolvedValue([ + { + code: 'unearned', + name: 'Unearned Badge', + description: 'Not earned yet', + category: 'other', + is_active: true, + earned: false, + }, + ]) + render() + await waitFor(() => { + expect(screen.getByText(/No badges earned yet/)).toBeInTheDocument() + }) + }) + + it('handles API errors gracefully', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.mocked(gamificationApi.getBadges).mockRejectedValue(new Error('API Error')) + render() + await waitFor(() => { + expect(consoleErrorSpy).toHaveBeenCalled() + }) + consoleErrorSpy.mockRestore() + }) +}) + + diff --git a/app/web/src/test/components/GamificationEngagement.test.tsx b/app/web/src/test/components/GamificationEngagement.test.tsx new file mode 100644 index 0000000..5c45001 --- /dev/null +++ b/app/web/src/test/components/GamificationEngagement.test.tsx @@ -0,0 +1,155 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen } from '../test-utils' +import { GamificationEngagement } from '@/components/GamificationEngagement' +import { mockRouter } from '../test-utils' +import type { Suggestion } from '@/types/learning' + +const mockGamificationContext = { + totalXp: 500, + level: 3, + currentStreak: 5, + xpToNextLevel: 100, + badges: [], + loading: false, + refreshState: vi.fn(), + showXpToast: vi.fn(), + showStreakToast: vi.fn(), + showBadgeModal: vi.fn(), + showLevelUpToast: vi.fn(), +} + +vi.mock('@/contexts/GamificationContext', () => ({ + useGamification: () => mockGamificationContext, +})) + +describe('GamificationEngagement', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders loading skeleton when loading', () => { + mockGamificationContext.loading = true + render() + expect(document.querySelectorAll('.mantine-Skeleton-root').length).toBeGreaterThan(0) + }) + + it('renders loading skeleton when loadingSuggestions is true', () => { + mockGamificationContext.loading = false + render() + expect(document.querySelectorAll('.mantine-Skeleton-root').length).toBeGreaterThan(0) + }) + + it('returns null when no streak, not close to level up, and no modules', () => { + mockGamificationContext.loading = false + mockGamificationContext.currentStreak = 0 + mockGamificationContext.xpToNextLevel = 100 + const { container } = render() + const textContent = container.textContent || '' + expect(textContent).not.toContain('streak') + expect(textContent).not.toContain('XP') + }) + + it('displays streak message when streak > 0', () => { + mockGamificationContext.loading = false + mockGamificationContext.currentStreak = 5 + mockGamificationContext.xpToNextLevel = 100 + render() + expect(screen.getByText(/5-day streak/i)).toBeInTheDocument() + }) + + it('displays close to level up message when xpToNextLevel <= 50', () => { + mockGamificationContext.loading = false + mockGamificationContext.currentStreak = 0 + mockGamificationContext.xpToNextLevel = 30 + render() + expect(screen.getByText(/30 XP away from leveling up/i)).toBeInTheDocument() + }) + + it('displays combined message when streak and close to level up', () => { + mockGamificationContext.loading = false + mockGamificationContext.currentStreak = 7 + mockGamificationContext.xpToNextLevel = 25 + render() + expect(screen.getByText(/7-day streak/i)).toBeInTheDocument() + expect(screen.getByText(/25 XP away/i)).toBeInTheDocument() + }) + + it('displays module completion message when suggestions available', () => { + mockGamificationContext.loading = false + mockGamificationContext.currentStreak = 0 + mockGamificationContext.xpToNextLevel = 100 + const suggestions: Suggestion[] = [ + { + id: '1', + moduleId: 'module-1', + reason: 'Test reason', + confidence: 0.8, + status: 'pending', + metadata: null, + }, + ] + render() + expect(screen.getByText(/Complete a new module/i)).toBeInTheDocument() + }) + + it('shows Start Learning button when suggestions available', () => { + mockGamificationContext.loading = false + mockGamificationContext.currentStreak = 0 + mockGamificationContext.xpToNextLevel = 100 + const suggestions: Suggestion[] = [ + { + id: '1', + moduleId: 'module-1', + reason: 'Test reason', + confidence: 0.8, + status: 'pending', + metadata: null, + }, + ] + render() + const button = screen.getByRole('button', { name: /start learning/i }) + expect(button).toBeInTheDocument() + }) + + it('navigates to learn page when Start Learning button is clicked', async () => { + const user = await import('@testing-library/user-event').then(m => m.default.setup()) + mockGamificationContext.loading = false + mockGamificationContext.currentStreak = 0 + mockGamificationContext.xpToNextLevel = 100 + const suggestions: Suggestion[] = [ + { + id: '1', + moduleId: 'module-1', + reason: 'Test reason', + confidence: 0.8, + status: 'pending', + metadata: null, + }, + ] + render() + const button = screen.getByRole('button', { name: /start learning/i }) + await user.click(button) + expect(mockRouter.push).toHaveBeenCalledWith('/learn') + }) + + it('displays combined message with streak, level up, and modules', () => { + mockGamificationContext.loading = false + mockGamificationContext.currentStreak = 10 + mockGamificationContext.xpToNextLevel = 20 + const suggestions: Suggestion[] = [ + { + id: '1', + moduleId: 'module-1', + reason: 'Test reason', + confidence: 0.8, + status: 'pending', + metadata: null, + }, + ] + render() + expect(screen.getByText(/10-day streak/i)).toBeInTheDocument() + expect(screen.getByText(/20 XP away/i)).toBeInTheDocument() + expect(screen.getByText(/Complete a module to level up/i)).toBeInTheDocument() + }) +}) + diff --git a/app/web/src/test/components/GradientBackground.test.tsx b/app/web/src/test/components/GradientBackground.test.tsx new file mode 100644 index 0000000..586d79d --- /dev/null +++ b/app/web/src/test/components/GradientBackground.test.tsx @@ -0,0 +1,26 @@ +import { describe, it, expect } from 'vitest' +import { render } from '../test-utils' +import GradientBackground from '@/components/GradientBackground' + +describe('GradientBackground', () => { + it('renders gradient background', () => { + const { container } = render() + const box = container.querySelector('div') + expect(box).toBeInTheDocument() + }) + + it('applies dark mode styles when colorScheme is dark', () => { + // This is tested through the component rendering + // The actual color scheme is handled by Mantine's useMantineColorScheme hook + const { container } = render() + expect(container.firstChild).toBeInTheDocument() + }) + + it('renders multiple gradient orbs', () => { + const { container } = render() + // Should have multiple Box elements for the gradient orbs + const boxes = container.querySelectorAll('div') + expect(boxes.length).toBeGreaterThan(1) + }) +}) + diff --git a/app/web/src/test/components/LevelUpModal.test.tsx b/app/web/src/test/components/LevelUpModal.test.tsx new file mode 100644 index 0000000..5c9489b --- /dev/null +++ b/app/web/src/test/components/LevelUpModal.test.tsx @@ -0,0 +1,39 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '../test-utils' +import userEvent from '@testing-library/user-event' +import { LevelUpModal } from '@/components/LevelUpModal' + +describe('LevelUpModal', () => { + it('renders modal when opened is true', () => { + render() + expect(screen.getByText('Level Up!')).toBeInTheDocument() + expect(screen.getByText("You've reached Level 5!")).toBeInTheDocument() + expect(screen.getByText('Keep learning and earning XP to level up even more!')).toBeInTheDocument() + }) + + it('does not render modal when opened is false', () => { + render() + expect(screen.queryByText('Level Up!')).not.toBeInTheDocument() + }) + + it('calls onClose when button is clicked', async () => { + const user = userEvent.setup() + const onClose = vi.fn() + render() + const button = screen.getByRole('button', { name: /awesome/i }) + await user.click(button) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('displays correct level number', () => { + render() + expect(screen.getByText("You've reached Level 10!")).toBeInTheDocument() + }) + + it('renders celebration emoji', () => { + render() + expect(screen.getByText('🎉')).toBeInTheDocument() + }) +}) + + diff --git a/app/web/src/test/components/StreakIndicator.test.tsx b/app/web/src/test/components/StreakIndicator.test.tsx new file mode 100644 index 0000000..886d02e --- /dev/null +++ b/app/web/src/test/components/StreakIndicator.test.tsx @@ -0,0 +1,78 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen } from '../test-utils' +import { StreakIndicator } from '@/components/StreakIndicator' + +const mockGamificationContext = { + totalXp: 500, + level: 3, + currentStreak: 5, + xpToNextLevel: 100, + badges: [], + loading: false, + refreshState: vi.fn(), + showXpToast: vi.fn(), + showStreakToast: vi.fn(), + showBadgeModal: vi.fn(), + showLevelUpToast: vi.fn(), +} + +vi.mock('@/contexts/GamificationContext', () => ({ + useGamification: () => mockGamificationContext, +})) + +describe('StreakIndicator', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders loading skeleton when loading is true (compact)', () => { + mockGamificationContext.loading = true + render() + expect(document.querySelectorAll('.mantine-Skeleton-root')).toHaveLength(2) + }) + + it('renders loading skeleton when loading is true (full)', () => { + mockGamificationContext.loading = true + render() + expect(document.querySelectorAll('.mantine-Skeleton-root')).toHaveLength(2) + }) + + it('returns null when streak is 0', () => { + mockGamificationContext.loading = false + mockGamificationContext.currentStreak = 0 + const { container } = render() + // Component returns null, but container may have style tags from Mantine + const textContent = container.textContent || '' + expect(textContent).not.toContain('streak') + expect(textContent).not.toContain('day') + }) + + it('renders streak indicator when streak > 0 (compact)', () => { + mockGamificationContext.loading = false + mockGamificationContext.currentStreak = 5 + render() + expect(screen.getByText('5')).toBeInTheDocument() + }) + + it('renders streak indicator when streak > 0 (full)', () => { + mockGamificationContext.loading = false + mockGamificationContext.currentStreak = 7 + render() + expect(screen.getByText('7-day streak')).toBeInTheDocument() + }) + + it('displays correct text for single day streak', () => { + mockGamificationContext.loading = false + mockGamificationContext.currentStreak = 1 + render() + expect(screen.getByText('1-day streak')).toBeInTheDocument() + }) + + it('displays correct text for multiple day streak', () => { + mockGamificationContext.loading = false + mockGamificationContext.currentStreak = 10 + render() + expect(screen.getByText('10-day streak')).toBeInTheDocument() + }) +}) + diff --git a/app/web/src/test/components/StreakModal.test.tsx b/app/web/src/test/components/StreakModal.test.tsx new file mode 100644 index 0000000..a1f126b --- /dev/null +++ b/app/web/src/test/components/StreakModal.test.tsx @@ -0,0 +1,39 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '../test-utils' +import userEvent from '@testing-library/user-event' +import { StreakModal } from '@/components/StreakModal' + +describe('StreakModal', () => { + it('renders modal when opened is true', () => { + render() + expect(screen.getByText('Streak Increased!')).toBeInTheDocument() + expect(screen.getByText('5-Day Streak')).toBeInTheDocument() + expect(screen.getByText('Keep it up! Complete a quiz every day to maintain your streak.')).toBeInTheDocument() + }) + + it('does not render modal when opened is false', () => { + render() + expect(screen.queryByText('Streak Increased!')).not.toBeInTheDocument() + }) + + it('calls onClose when button is clicked', async () => { + const user = userEvent.setup() + const onClose = vi.fn() + render() + const button = screen.getByRole('button', { name: /awesome/i }) + await user.click(button) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('displays correct streak number', () => { + render() + expect(screen.getByText('10-Day Streak')).toBeInTheDocument() + }) + + it('renders flame emoji', () => { + render() + expect(screen.getByText('🔥')).toBeInTheDocument() + }) +}) + + diff --git a/app/web/src/test/components/SuggestionsWidget.test.tsx b/app/web/src/test/components/SuggestionsWidget.test.tsx new file mode 100644 index 0000000..799953f --- /dev/null +++ b/app/web/src/test/components/SuggestionsWidget.test.tsx @@ -0,0 +1,164 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen } from '../test-utils' +import userEvent from '@testing-library/user-event' +import { SuggestionsWidget } from '@/components/SuggestionsWidget' +import { mockRouter } from '../test-utils' +import type { Suggestion } from '@/types/learning' + +describe('SuggestionsWidget', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders loading state', () => { + render() + expect(screen.getByText('Loading suggestions...')).toBeInTheDocument() + }) + + it('returns null when no incomplete suggestions', () => { + const suggestions: Suggestion[] = [ + { + id: '1', + moduleId: 'module-1', + reason: 'Test reason', + confidence: 0.8, + status: 'completed', + metadata: null, + }, + ] + const { container } = render() + const textContent = container.textContent || '' + expect(textContent).not.toContain('AI Suggestions') + }) + + it('renders incomplete suggestions', () => { + const suggestions: Suggestion[] = [ + { + id: '1', + moduleId: 'module-1', + reason: 'Test reason 1', + confidence: 0.8, + status: 'pending', + metadata: null, + }, + { + id: '2', + moduleId: 'module-2', + reason: 'Test reason 2', + confidence: 0.6, + status: 'pending', + metadata: null, + }, + ] + render() + expect(screen.getByText('AI Suggestions')).toBeInTheDocument() + expect(screen.getByText('Test reason 1')).toBeInTheDocument() + expect(screen.getByText('Test reason 2')).toBeInTheDocument() + }) + + it('shows only top 3 suggestions', () => { + const suggestions: Suggestion[] = [ + { id: '1', moduleId: 'module-1', reason: 'Reason 1', confidence: 0.8, status: 'pending', metadata: null }, + { id: '2', moduleId: 'module-2', reason: 'Reason 2', confidence: 0.7, status: 'pending', metadata: null }, + { id: '3', moduleId: 'module-3', reason: 'Reason 3', confidence: 0.6, status: 'pending', metadata: null }, + { id: '4', moduleId: 'module-4', reason: 'Reason 4', confidence: 0.5, status: 'pending', metadata: null }, + ] + render() + expect(screen.getByText('Reason 1')).toBeInTheDocument() + expect(screen.getByText('Reason 2')).toBeInTheDocument() + expect(screen.getByText('Reason 3')).toBeInTheDocument() + expect(screen.queryByText('Reason 4')).not.toBeInTheDocument() + }) + + it('filters out completed suggestions', () => { + const suggestions: Suggestion[] = [ + { id: '1', moduleId: 'module-1', reason: 'Pending', confidence: 0.8, status: 'pending', metadata: null }, + { id: '2', moduleId: 'module-2', reason: 'Completed', confidence: 0.7, status: 'completed', metadata: null }, + ] + render() + expect(screen.getByText('Pending')).toBeInTheDocument() + expect(screen.queryByText('Completed')).not.toBeInTheDocument() + }) + + it('displays confidence badges correctly', () => { + const suggestions: Suggestion[] = [ + { id: '1', moduleId: 'module-1', reason: 'High', confidence: 0.9, status: 'pending', metadata: null }, + { id: '2', moduleId: 'module-2', reason: 'Medium', confidence: 0.6, status: 'pending', metadata: null }, + { id: '3', moduleId: 'module-3', reason: 'Low', confidence: 0.3, status: 'pending', metadata: null }, + ] + render() + expect(screen.getByText('High Match')).toBeInTheDocument() + expect(screen.getByText('Medium Match')).toBeInTheDocument() + expect(screen.getByText('Low Match')).toBeInTheDocument() + }) + + it('displays Match badge when confidence is null', () => { + const suggestions: Suggestion[] = [ + { id: '1', moduleId: 'module-1', reason: 'No confidence', confidence: null, status: 'pending', metadata: null }, + ] + render() + // Badge might not render if confidence is null, check if component renders + expect(screen.getByText('No confidence')).toBeInTheDocument() + }) + + it('navigates to learn page when View All is clicked', async () => { + const user = userEvent.setup() + const suggestions: Suggestion[] = [ + { id: '1', moduleId: 'module-1', reason: 'Test', confidence: 0.8, status: 'pending', metadata: null }, + ] + render() + const button = screen.getByRole('button', { name: /view all/i }) + await user.click(button) + expect(mockRouter.push).toHaveBeenCalledWith('/learn') + }) + + it('navigates to module page when Start Module is clicked', async () => { + const user = userEvent.setup() + const suggestions: Suggestion[] = [ + { id: '1', moduleId: 'module-1', reason: 'Test', confidence: 0.8, status: 'pending', metadata: null }, + ] + render() + const button = screen.getByRole('button', { name: /start module/i }) + await user.click(button) + expect(mockRouter.push).toHaveBeenCalledWith('/modules/module-1') + }) + + it('displays correct icon for investment type', () => { + const suggestions: Suggestion[] = [ + { + id: '1', + moduleId: 'module-1', + reason: 'Test', + confidence: 0.8, + status: 'pending', + metadata: { type: 'investment', topic: 'Investing' }, + }, + ] + render() + expect(screen.getByText('Investing')).toBeInTheDocument() + }) + + it('displays correct icon for warning type', () => { + const suggestions: Suggestion[] = [ + { + id: '1', + moduleId: 'module-1', + reason: 'Test', + confidence: 0.8, + status: 'pending', + metadata: { type: 'warning', topic: 'Warning' }, + }, + ] + render() + expect(screen.getByText('Warning')).toBeInTheDocument() + }) + + it('displays default topic when metadata is missing', () => { + const suggestions: Suggestion[] = [ + { id: '1', moduleId: 'module-1', reason: 'Test', confidence: 0.8, status: 'pending', metadata: null }, + ] + render() + expect(screen.getByText('General')).toBeInTheDocument() + }) +}) + diff --git a/app/web/src/test/components/ValueChart.test.tsx b/app/web/src/test/components/ValueChart.test.tsx new file mode 100644 index 0000000..c3993c5 --- /dev/null +++ b/app/web/src/test/components/ValueChart.test.tsx @@ -0,0 +1,126 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '../test-utils' +import userEvent from '@testing-library/user-event' +import { ValueChart } from '@/components/ValueChart' +import type { SnapshotPoint } from '@/types/portfolio' + +const mockData: SnapshotPoint[] = [ + { + asOf: '2024-01-01T00:00:00Z', + totalValue: 10000, + }, + { + asOf: '2024-01-02T00:00:00Z', + totalValue: 10500, + }, + { + asOf: '2024-01-03T00:00:00Z', + totalValue: 11000, + }, +] + +describe('ValueChart', () => { + it('renders chart with data', () => { + render() + expect(screen.getByText('Portfolio Value Over Time')).toBeInTheDocument() + }) + + it('shows message when data is empty', () => { + render() + expect(screen.getByText('No snapshot data available')).toBeInTheDocument() + }) + + it('calls onRefresh when refresh button is clicked', async () => { + const user = userEvent.setup() + const onRefresh = vi.fn().mockResolvedValue(undefined) + render() + // Find the refresh button by role + const refreshButton = screen.getByRole('button') + expect(refreshButton).toBeInTheDocument() + await user.click(refreshButton) + expect(onRefresh).toHaveBeenCalledTimes(1) + }) + + it('calls onRangeChange when range is changed', async () => { + const user = userEvent.setup() + const onRangeChange = vi.fn() + render() + const rangeButton = screen.getByRole('radio', { name: /1w/i }) + await user.click(rangeButton) + expect(onRangeChange).toHaveBeenCalled() + }) + + it('displays overlay value when provided', () => { + render( + + ) + expect(screen.getByText(/\$12,000\.00/)).toBeInTheDocument() + }) + + it('handles string totalValue correctly', () => { + const stringData: SnapshotPoint[] = [ + { + asOf: '2024-01-01T00:00:00Z', + totalValue: 10000, + }, + ] + render() + expect(screen.getByText('Portfolio Value Over Time')).toBeInTheDocument() + }) + + it('uses default range when not provided', () => { + render() + expect(screen.getByText('Portfolio Value Over Time')).toBeInTheDocument() + }) + + it('handles empty overlay value', () => { + render( + + ) + expect(screen.getByText('Portfolio Value Over Time')).toBeInTheDocument() + }) + + it('handles different granularities', () => { + const hourlyData: SnapshotPoint[] = [ + { asOf: '2024-01-01T10:00:00Z', totalValue: 10000 }, + { asOf: '2024-01-01T11:00:00Z', totalValue: 10100 }, + ] + render() + expect(screen.getByText('Portfolio Value Over Time')).toBeInTheDocument() + }) + + it('handles negative percentage change', () => { + render( + + ) + expect(screen.getByText(/\$10,000\.00/)).toBeInTheDocument() + }) + + it('sorts data by date correctly', () => { + const unsortedData: SnapshotPoint[] = [ + { asOf: '2024-01-03T00:00:00Z', totalValue: 11000 }, + { asOf: '2024-01-01T00:00:00Z', totalValue: 10000 }, + { asOf: '2024-01-02T00:00:00Z', totalValue: 10500 }, + ] + render() + expect(screen.getByText('Portfolio Value Over Time')).toBeInTheDocument() + }) +}) + diff --git a/app/web/src/test/components/XPBar.test.tsx b/app/web/src/test/components/XPBar.test.tsx new file mode 100644 index 0000000..f82c271 --- /dev/null +++ b/app/web/src/test/components/XPBar.test.tsx @@ -0,0 +1,126 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen } from '../test-utils' +import { XPBar } from '@/components/XPBar' + +const mockGamificationContext = { + totalXp: 500, + level: 3, + currentStreak: 5, + xpToNextLevel: 100, + badges: [], + loading: false, + refreshState: vi.fn(), + showXpToast: vi.fn(), + showStreakToast: vi.fn(), + showBadgeModal: vi.fn(), + showLevelUpToast: vi.fn(), +} + +vi.mock('@/contexts/GamificationContext', () => ({ + useGamification: () => mockGamificationContext, +})) + +describe('XPBar', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders loading skeleton when loading is true (compact)', () => { + mockGamificationContext.loading = true + render() + expect(document.querySelectorAll('.mantine-Skeleton-root')).toHaveLength(2) + }) + + it('renders loading skeleton when loading is true (full)', () => { + mockGamificationContext.loading = true + render() + expect(document.querySelectorAll('.mantine-Skeleton-root')).toHaveLength(3) + }) + + it('renders level and progress bar when not loading (compact)', () => { + mockGamificationContext.loading = false + mockGamificationContext.level = 3 + mockGamificationContext.totalXp = 500 + mockGamificationContext.xpToNextLevel = 100 + render() + expect(screen.getByText('Level 3')).toBeInTheDocument() + }) + + it('renders level and progress bar when not loading (full)', () => { + mockGamificationContext.loading = false + mockGamificationContext.level = 3 + mockGamificationContext.totalXp = 500 + mockGamificationContext.xpToNextLevel = 100 + render() + expect(screen.getByText('Level 3')).toBeInTheDocument() + expect(screen.getByText('100 XP to next')).toBeInTheDocument() + }) + + it('shows MAX when level is 10 or higher', () => { + mockGamificationContext.loading = false + mockGamificationContext.level = 10 + mockGamificationContext.totalXp = 5000 + mockGamificationContext.xpToNextLevel = 0 + render() + expect(screen.getByText('MAX')).toBeInTheDocument() + }) + + it('calculates progress correctly for level 1', () => { + mockGamificationContext.loading = false + mockGamificationContext.level = 1 + mockGamificationContext.totalXp = 50 + mockGamificationContext.xpToNextLevel = 150 + render() + expect(screen.getByText('Level 1')).toBeInTheDocument() + }) + + it('calculates progress correctly for level 5', () => { + mockGamificationContext.loading = false + mockGamificationContext.level = 5 + mockGamificationContext.totalXp = 800 + mockGamificationContext.xpToNextLevel = 200 + render() + expect(screen.getByText('Level 5')).toBeInTheDocument() + }) + + it('calculates progress correctly for level 6+', () => { + mockGamificationContext.loading = false + mockGamificationContext.level = 7 + mockGamificationContext.totalXp = 2000 + mockGamificationContext.xpToNextLevel = 500 + render() + expect(screen.getByText('Level 7')).toBeInTheDocument() + }) + + it('uses correct color for level >= 7', () => { + mockGamificationContext.loading = false + mockGamificationContext.level = 7 + mockGamificationContext.totalXp = 2000 + mockGamificationContext.xpToNextLevel = 500 + const { container } = render() + const progressBar = container.querySelector('.mantine-Progress-root') + expect(progressBar).toBeInTheDocument() + }) + + it('uses correct color for level >= 4 and < 7', () => { + mockGamificationContext.loading = false + mockGamificationContext.level = 5 + mockGamificationContext.totalXp = 1000 + mockGamificationContext.xpToNextLevel = 200 + const { container } = render() + const progressBar = container.querySelector('.mantine-Progress-root') + expect(progressBar).toBeInTheDocument() + }) + + it('uses correct color for level < 4', () => { + mockGamificationContext.loading = false + mockGamificationContext.level = 2 + mockGamificationContext.totalXp = 150 + mockGamificationContext.xpToNextLevel = 50 + const { container } = render() + const progressBar = container.querySelector('.mantine-Progress-root') + expect(progressBar).toBeInTheDocument() + }) +}) + + diff --git a/app/web/src/test/contexts/AuthContext.test.tsx b/app/web/src/test/contexts/AuthContext.test.tsx new file mode 100644 index 0000000..6949d01 --- /dev/null +++ b/app/web/src/test/contexts/AuthContext.test.tsx @@ -0,0 +1,198 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor, act } from '../test-utils' +import { AuthProvider, useAuth } from '@/contexts/AuthContext' +import { supabase } from '@/lib/supabase' +import { mockRouter } from '../test-utils' + +const TestComponent = () => { + const { user, loading, signUp, signIn, signInWithGoogle, signOut } = useAuth() + return ( +
+
{user?.email || 'null'}
+
{loading ? 'loading' : 'loaded'}
+ + + + +
+ ) +} + +vi.mock('@/lib/supabase', () => ({ + supabase: { + auth: { + getSession: vi.fn(), + onAuthStateChange: vi.fn(), + signUp: vi.fn(), + signInWithPassword: vi.fn(), + signInWithOAuth: vi.fn(), + signOut: vi.fn(), + }, + }, +})) + +describe('AuthContext', () => { + beforeEach(() => { + vi.clearAllMocks() + mockRouter.push = vi.fn() + vi.mocked(supabase.auth.getSession).mockResolvedValue({ + data: { session: null }, + error: null, + }) + vi.mocked(supabase.auth.onAuthStateChange).mockReturnValue({ + data: { subscription: { unsubscribe: vi.fn() } }, + } as unknown as ReturnType) + }) + + it('provides initial state', async () => { + render( + + + + ) + await waitFor(() => { + expect(screen.getByTestId('loading')).toHaveTextContent('loaded') + }) + expect(screen.getByTestId('user')).toHaveTextContent('null') + }) + + it('handles sign up', async () => { + vi.mocked(supabase.auth.signUp).mockResolvedValue({ + data: { user: null, session: null }, + error: null, + }) + render( + + + + ) + await waitFor(() => { + expect(screen.getByTestId('loading')).toHaveTextContent('loaded') + }) + const signUpButton = screen.getByText('Sign Up') + await signUpButton.click() + await waitFor(() => { + expect(supabase.auth.signUp).toHaveBeenCalledWith({ + email: 'test@example.com', + password: 'password', + options: { + data: { + full_name: 'Test User', + }, + }, + }) + }) + }) + + it('handles sign in', async () => { + vi.mocked(supabase.auth.signInWithPassword).mockResolvedValue({ + data: { + user: { id: '1', email: 'test@example.com' } as unknown as { id: string; email: string }, + session: { + access_token: 'token', + refresh_token: 'refresh', + expires_in: 3600, + token_type: 'bearer', + user: { id: '1', email: 'test@example.com' } as unknown as { id: string; email: string }, + } as unknown as { access_token: string; refresh_token: string; expires_in: number; token_type: string; user: { id: string; email: string } }, + }, + error: null, + } as unknown as Awaited>) + render( + + + + ) + await waitFor(() => { + expect(screen.getByTestId('loading')).toHaveTextContent('loaded') + }) + const signInButton = screen.getByText('Sign In') + await signInButton.click() + await waitFor(() => { + expect(supabase.auth.signInWithPassword).toHaveBeenCalledWith({ + email: 'test@example.com', + password: 'password', + }) + expect(mockRouter.push).toHaveBeenCalledWith('/') + }) + }) + + it('handles sign in with Google', async () => { + vi.mocked(supabase.auth.signInWithOAuth).mockResolvedValue({ + data: { provider: 'google', url: 'https://example.com' }, + error: null, + }) + render( + + + + ) + await waitFor(() => { + expect(screen.getByTestId('loading')).toHaveTextContent('loaded') + }) + const googleButton = screen.getByText('Sign In Google') + await googleButton.click() + await waitFor(() => { + expect(supabase.auth.signInWithOAuth).toHaveBeenCalledWith({ + provider: 'google', + options: { + redirectTo: process.env.NEXT_PUBLIC_SITE_URL, + }, + }) + }) + }) + + it('handles sign out', async () => { + vi.mocked(supabase.auth.signOut).mockResolvedValue({ error: null }) + render( + + + + ) + await waitFor(() => { + expect(screen.getByTestId('loading')).toHaveTextContent('loaded') + }) + const signOutButton = screen.getByText('Sign Out') + await signOutButton.click() + await waitFor(() => { + expect(supabase.auth.signOut).toHaveBeenCalled() + expect(mockRouter.push).toHaveBeenCalledWith('/login') + }) + }) + + it('updates state on auth change', async () => { + let authChangeCallback: (event: 'SIGNED_IN' | 'SIGNED_OUT' | 'TOKEN_REFRESHED' | 'USER_UPDATED' | 'PASSWORD_RECOVERY', session: { user: { id: string; email: string } } | null) => void + vi.mocked(supabase.auth.onAuthStateChange).mockImplementation((callback) => { + authChangeCallback = callback as typeof authChangeCallback + return { + data: { subscription: { unsubscribe: vi.fn() } }, + } as unknown as ReturnType + }) + render( + + + + ) + await waitFor(() => { + expect(screen.getByTestId('loading')).toHaveTextContent('loaded') + }) + const mockUser = { id: '1', email: 'test@example.com' } + const mockSession = { user: mockUser } as unknown as { user: { id: string; email: string } } + // Use act to wrap state updates + await act(async () => { + authChangeCallback!('SIGNED_IN', mockSession) + }) + await waitFor(() => { + expect(screen.getByTestId('user')).toHaveTextContent('test@example.com') + }) + }) + + it('throws error when used outside provider', () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => { }) + expect(() => { + render() + }).toThrow() + consoleError.mockRestore() + }) +}) + diff --git a/app/web/src/test/contexts/GamificationContext.test.tsx b/app/web/src/test/contexts/GamificationContext.test.tsx new file mode 100644 index 0000000..fd8e70a --- /dev/null +++ b/app/web/src/test/contexts/GamificationContext.test.tsx @@ -0,0 +1,227 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '../test-utils' +import { GamificationProvider, useGamification } from '@/contexts/GamificationContext' +import { gamificationApi } from '@/lib/api' +import type { GamificationStateResponse } from '@/lib/api' + +const mockAuthContext = { + user: { id: '1', email: 'test@example.com' }, + session: null, + loading: false, + signUp: vi.fn(), + signIn: vi.fn(), + signInWithGoogle: vi.fn(), + signOut: vi.fn(), +} + +vi.mock('@/contexts/AuthContext', () => ({ + useAuth: () => mockAuthContext, +})) + +vi.mock('@/lib/api', () => ({ + gamificationApi: { + getState: vi.fn(), + sendEvent: vi.fn(), + }, +})) + +const TestComponent = () => { + const { totalXp, level, currentStreak, xpToNextLevel, badges, loading, refreshState, showXpToast, showBadgeModal, showLevelUpToast } = useGamification() + return ( +
+
{totalXp}
+
{level}
+
{currentStreak}
+
{xpToNextLevel}
+
{badges.length}
+
{loading ? 'loading' : 'loaded'}
+ + + + +
+ ) +} + +describe('GamificationContext', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('provides default state when user is not authenticated', async () => { + mockAuthContext.user = null as unknown as { id: string; email: string } + render( + + + + ) + await waitFor(() => { + expect(screen.getByTestId('loading')).toHaveTextContent('loaded') + }, { timeout: 3000 }) + }) + + it('fetches state when user is authenticated', async () => { + mockAuthContext.user = { id: '1', email: 'test@example.com' } + const mockState: GamificationStateResponse = { + total_xp: 500, + level: 3, + current_streak: 5, + xp_to_next_level: 100, + badges: [], + } + vi.mocked(gamificationApi.getState).mockResolvedValue(mockState) + render( + + + + ) + await waitFor(() => { + expect(screen.getByTestId('totalXp')).toHaveTextContent('500') + expect(screen.getByTestId('level')).toHaveTextContent('3') + expect(screen.getByTestId('currentStreak')).toHaveTextContent('5') + expect(screen.getByTestId('xpToNextLevel')).toHaveTextContent('100') + }, { timeout: 3000 }) + }) + + it('refreshes state when refreshState is called', async () => { + mockAuthContext.user = { id: '1', email: 'test@example.com' } + const mockState: GamificationStateResponse = { + total_xp: 500, + level: 3, + current_streak: 5, + xp_to_next_level: 100, + badges: [], + } + vi.mocked(gamificationApi.getState).mockResolvedValue(mockState) + render( + + + + ) + await waitFor(() => { + expect(screen.getByTestId('totalXp')).toHaveTextContent('500') + }, { timeout: 3000 }) + const refreshButton = screen.getByText('Refresh') + await refreshButton.click() + await waitFor(() => { + expect(gamificationApi.getState).toHaveBeenCalledTimes(2) + }, { timeout: 3000 }) + }) + + it('shows XP toast', async () => { + mockAuthContext.user = { id: '1', email: 'test@example.com' } + vi.mocked(gamificationApi.getState).mockResolvedValue({ + total_xp: 0, + level: 1, + current_streak: 0, + xp_to_next_level: 200, + badges: [], + }) + render( + + + + ) + await waitFor(() => { + expect(screen.getByTestId('loading')).toHaveTextContent('loaded') + }, { timeout: 3000 }) + const showXpButton = screen.getByText('Show XP') + await showXpButton.click() + await waitFor(() => { + expect(screen.getByText('+100 XP')).toBeInTheDocument() + }, { timeout: 3000 }) + }) + + it('shows badge modal', async () => { + mockAuthContext.user = { id: '1', email: 'test@example.com' } + vi.mocked(gamificationApi.getState).mockResolvedValue({ + total_xp: 0, + level: 1, + current_streak: 0, + xp_to_next_level: 200, + badges: [], + }) + render( + + + + ) + await waitFor(() => { + expect(screen.getByTestId('loading')).toHaveTextContent('loaded') + }, { timeout: 3000 }) + const showBadgeButton = screen.getByText('Show Badge') + await showBadgeButton.click() + await waitFor(() => { + expect(screen.getByText('🏆 Badge Earned!')).toBeInTheDocument() + expect(screen.getByText('Test')).toBeInTheDocument() + }, { timeout: 3000 }) + }) + + it('shows level up modal', async () => { + mockAuthContext.user = { id: '1', email: 'test@example.com' } + vi.mocked(gamificationApi.getState).mockResolvedValue({ + total_xp: 0, + level: 5, + current_streak: 0, + xp_to_next_level: 200, + badges: [], + }) + render( + + + + ) + await waitFor(() => { + expect(screen.getByTestId('loading')).toHaveTextContent('loaded') + }, { timeout: 3000 }) + const levelUpButton = screen.getByText('Level Up') + await levelUpButton.click() + await waitFor(() => { + expect(screen.getByText('Level Up!')).toBeInTheDocument() + expect(screen.getByText("You've reached Level 5!")).toBeInTheDocument() + }, { timeout: 3000 }) + }) + + it('handles login event', async () => { + mockAuthContext.user = { id: '1', email: 'test@example.com' } + vi.mocked(gamificationApi.getState).mockResolvedValue({ + total_xp: 0, + level: 1, + current_streak: 0, + xp_to_next_level: 200, + badges: [], + }) + vi.mocked(gamificationApi.sendEvent).mockResolvedValue({ + total_xp: 10, + level: 1, + current_streak: 1, + xp_gained: 10, + level_up: false, + streak_incremented: false, + new_badges: [], + xp_to_next_level: 190, + }) + render( + + + + ) + await waitFor(() => { + expect(screen.getByTestId('loading')).toHaveTextContent('loaded') + }, { timeout: 3000 }) + window.dispatchEvent(new CustomEvent('gamification:login')) + await waitFor(() => { + expect(gamificationApi.sendEvent).toHaveBeenCalledWith({ event_type: 'login' }) + }, { timeout: 3000 }) + }) + + it('throws error when used outside provider', () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => { }) + expect(() => { + render() + }).toThrow() + consoleError.mockRestore() + }) +}) + + diff --git a/app/web/src/test/features/onboarding/constants.test.ts b/app/web/src/test/features/onboarding/constants.test.ts new file mode 100644 index 0000000..1da6723 --- /dev/null +++ b/app/web/src/test/features/onboarding/constants.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest' +import { + financialGoalsOptions, + incomeRanges, + investmentAmounts, + riskToleranceOptions, + countries, + TOTAL_STEPS, +} from '@/features/onboarding/constants' + +describe('Onboarding Constants', () => { + it('exports financial goals options', () => { + expect(financialGoalsOptions).toBeInstanceOf(Array) + expect(financialGoalsOptions.length).toBeGreaterThan(0) + expect(financialGoalsOptions).toContain('Saving for retirement') + }) + + it('exports income ranges', () => { + expect(incomeRanges).toBeInstanceOf(Array) + expect(incomeRanges.length).toBeGreaterThan(0) + expect(incomeRanges[0]).toHaveProperty('value') + expect(incomeRanges[0]).toHaveProperty('label') + }) + + it('exports investment amounts', () => { + expect(investmentAmounts).toBeInstanceOf(Array) + expect(investmentAmounts.length).toBeGreaterThan(0) + expect(investmentAmounts[0]).toHaveProperty('value') + expect(investmentAmounts[0]).toHaveProperty('label') + }) + + it('exports risk tolerance options', () => { + expect(riskToleranceOptions).toBeInstanceOf(Array) + expect(riskToleranceOptions.length).toBeGreaterThan(0) + expect(riskToleranceOptions).toContain('Moderate') + }) + + it('exports countries list', () => { + expect(countries).toBeInstanceOf(Array) + expect(countries.length).toBeGreaterThan(0) + const usCountry = countries.find(c => c.value === 'US') + expect(usCountry).toBeDefined() + expect(usCountry?.label).toBe('United States') + }) + + it('exports total steps constant', () => { + expect(TOTAL_STEPS).toBe(5) + }) +}) + diff --git a/app/web/src/test/features/onboarding/hooks/useOnboarding.test.tsx b/app/web/src/test/features/onboarding/hooks/useOnboarding.test.tsx new file mode 100644 index 0000000..0469428 --- /dev/null +++ b/app/web/src/test/features/onboarding/hooks/useOnboarding.test.tsx @@ -0,0 +1,126 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { renderHook, waitFor, act } from '../../../test-utils' +import { useOnboarding } from '@/features/onboarding/hooks/useOnboarding' +import { usersApi } from '@/lib/api' +import { mockRouter } from '../../../test-utils' + +vi.mock('@/lib/api', () => ({ + usersApi: { + updateFinancialProfile: vi.fn(), + }, +})) + +vi.mock('next/router', () => ({ + useRouter: () => mockRouter, +})) + +describe('useOnboarding', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('initializes with default values', () => { + const { result } = renderHook(() => useOnboarding()) + expect(result.current.currentStep).toBe(1) + expect(result.current.totalSteps).toBe(5) + expect(result.current.data.financialGoals).toBe('Saving for retirement') + expect(result.current.data.age).toBe(25) + }) + + it('updates data correctly', () => { + const { result } = renderHook(() => useOnboarding()) + act(() => { + result.current.updateData({ age: 30 }) + }) + expect(result.current.data.age).toBe(30) + expect(result.current.data.financialGoals).toBe('Saving for retirement') // Other fields unchanged + }) + + it('moves to next step when handleNext is called', () => { + const { result } = renderHook(() => useOnboarding()) + act(() => { + result.current.handleNext() + }) + expect(result.current.currentStep).toBe(2) + }) + + it('moves to previous step when handlePrevious is called', () => { + const { result } = renderHook(() => useOnboarding()) + act(() => { + result.current.handleNext() + }) + expect(result.current.currentStep).toBe(2) + act(() => { + result.current.handlePrevious() + }) + expect(result.current.currentStep).toBe(1) + }) + + it('does not go below step 1', () => { + const { result } = renderHook(() => useOnboarding()) + act(() => { + result.current.handlePrevious() + }) + expect(result.current.currentStep).toBe(1) + }) + + it('completes onboarding on last step', async () => { + vi.mocked(usersApi.updateFinancialProfile).mockResolvedValue({ success: true } as unknown as Awaited>) + const { result } = renderHook(() => useOnboarding()) + act(() => { + result.current.updateData({ financialGoals: 'Saving for retirement' }) + result.current.updateData({ age: 25, annualIncome: '50k-75k', country: 'US' }) + result.current.updateData({ investmentAmount: '5k-10k', riskTolerance: 'Moderate' }) + // Move to last step + for (let i = 1; i < 5; i++) { + result.current.handleNext() + } + }) + await act(async () => { + await result.current.handleComplete() + }) + await waitFor(() => { + expect(usersApi.updateFinancialProfile).toHaveBeenCalled() + expect(mockRouter.push).toHaveBeenCalledWith('/dashboard') + }) + }) + + it('handles completion errors', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {}) + vi.mocked(usersApi.updateFinancialProfile).mockRejectedValue(new Error('API Error')) + const { result } = renderHook(() => useOnboarding()) + await act(async () => { + await result.current.handleComplete() + }) + await waitFor(() => { + expect(consoleErrorSpy).toHaveBeenCalled() + expect(alertSpy).toHaveBeenCalled() + }) + consoleErrorSpy.mockRestore() + alertSpy.mockRestore() + }) + + it('calculates canProceed correctly', () => { + const { result } = renderHook(() => useOnboarding()) + expect(result.current.canProceed).toBe(true) // Step 1 with default financialGoals + act(() => { + result.current.updateData({ financialGoals: '' }) + }) + expect(result.current.canProceed).toBe(false) + }) + + it('identifies first and last steps correctly', () => { + const { result } = renderHook(() => useOnboarding()) + expect(result.current.isFirstStep).toBe(true) + expect(result.current.isLastStep).toBe(false) + act(() => { + for (let i = 1; i < 5; i++) { + result.current.handleNext() + } + }) + expect(result.current.isFirstStep).toBe(false) + expect(result.current.isLastStep).toBe(true) + }) +}) + diff --git a/app/web/src/test/features/onboarding/utils/validation.test.ts b/app/web/src/test/features/onboarding/utils/validation.test.ts new file mode 100644 index 0000000..4633711 --- /dev/null +++ b/app/web/src/test/features/onboarding/utils/validation.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from 'vitest' +import { validateStep, canProceedToNextStep, getExperienceLabel } from '@/features/onboarding/utils/validation' +import type { OnboardingData } from '@/features/onboarding/types' + +const validData: OnboardingData = { + financialGoals: 'Saving for retirement', + investingExperience: 2, + age: 25, + annualIncome: '50k-75k', + investmentAmount: '5k-10k', + riskTolerance: 'Moderate', + country: 'US', +} + +describe('Onboarding Validation', () => { + describe('validateStep', () => { + it('validates step 1 requires financialGoals', () => { + expect(validateStep(1, validData)).toBe(true) + expect(validateStep(1, { ...validData, financialGoals: '' })).toBe(false) + }) + + it('validates step 2 requires age, annualIncome, and country', () => { + expect(validateStep(2, validData)).toBe(true) + expect(validateStep(2, { ...validData, age: 0 })).toBe(false) + expect(validateStep(2, { ...validData, annualIncome: '' })).toBe(false) + expect(validateStep(2, { ...validData, country: '' })).toBe(false) + }) + + it('validates step 3 requires investmentAmount and riskTolerance', () => { + expect(validateStep(3, validData)).toBe(true) + expect(validateStep(3, { ...validData, investmentAmount: '' })).toBe(false) + expect(validateStep(3, { ...validData, riskTolerance: '' })).toBe(false) + }) + + it('always validates step 4 and 5', () => { + expect(validateStep(4, validData)).toBe(true) + expect(validateStep(5, validData)).toBe(true) + expect(validateStep(4, { ...validData, financialGoals: '' })).toBe(true) + }) + + it('returns false for invalid step numbers', () => { + expect(validateStep(0, validData)).toBe(false) + expect(validateStep(6, validData)).toBe(false) + }) + }) + + describe('canProceedToNextStep', () => { + it('returns true when step is valid and not last step', () => { + expect(canProceedToNextStep(1, validData)).toBe(true) + expect(canProceedToNextStep(2, validData)).toBe(true) + expect(canProceedToNextStep(3, validData)).toBe(true) + }) + + it('returns false when step is invalid', () => { + expect(canProceedToNextStep(1, { ...validData, financialGoals: '' })).toBe(false) + }) + + it('returns false when on last step', () => { + expect(canProceedToNextStep(5, validData)).toBe(false) + }) + }) + + describe('getExperienceLabel', () => { + it('returns correct label for each experience level', () => { + expect(getExperienceLabel(0)).toBe('Not at all') + expect(getExperienceLabel(1)).toBe('Beginner') + expect(getExperienceLabel(2)).toBe('Intermediate') + expect(getExperienceLabel(3)).toBe('Advanced') + expect(getExperienceLabel(4)).toBe('Expert') + }) + + it('returns default label for invalid experience', () => { + expect(getExperienceLabel(5)).toBe('Beginner') + expect(getExperienceLabel(-1)).toBe('Beginner') + }) + }) +}) + diff --git a/app/web/src/test/features/portfolio/hooks/usePortfolio.test.tsx b/app/web/src/test/features/portfolio/hooks/usePortfolio.test.tsx new file mode 100644 index 0000000..3c50c62 --- /dev/null +++ b/app/web/src/test/features/portfolio/hooks/usePortfolio.test.tsx @@ -0,0 +1,61 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { renderHook, waitFor } from '../../../test-utils' +import { usePortfolio } from '@/features/portfolio/hooks/usePortfolio' +import { portfolioApi } from '@/lib/api' + +vi.mock('@/lib/api', () => ({ + portfolioApi: { + getPortfolio: vi.fn(), + }, +})) + +describe('usePortfolio', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('initializes with null portfolio and loading true', () => { + const { result } = renderHook(() => usePortfolio()) + expect(result.current.portfolio).toBeNull() + expect(result.current.loading).toBe(true) + expect(result.current.error).toBeNull() + }) + + it('loads portfolio successfully', async () => { + const mockPortfolio = { + holdings: [], + totals: { totalValue: 10000, totalCostBasis: 9000, unrealizedPL: 1000, dailyPL: 100 }, + baseCurrency: 'USD', + } + vi.mocked(portfolioApi.getPortfolio).mockResolvedValue(mockPortfolio as unknown as Awaited>) + const { result } = renderHook(() => usePortfolio()) + await result.current.loadPortfolio() + await waitFor(() => { + expect(result.current.loading).toBe(false) + }) + expect(result.current.portfolio).toEqual(mockPortfolio) + expect(result.current.error).toBeNull() + }) + + it('handles loading errors', async () => { + const errorMessage = 'Failed to load portfolio' + vi.mocked(portfolioApi.getPortfolio).mockRejectedValue(new Error(errorMessage)) + const { result } = renderHook(() => usePortfolio()) + await result.current.loadPortfolio() + await waitFor(() => { + expect(result.current.loading).toBe(false) + }) + expect(result.current.error).toBe(errorMessage) + expect(result.current.portfolio).toBeNull() + }) + + it('sets loading to false after error', async () => { + vi.mocked(portfolioApi.getPortfolio).mockRejectedValue(new Error('Error')) + const { result } = renderHook(() => usePortfolio()) + await result.current.loadPortfolio() + await waitFor(() => { + expect(result.current.loading).toBe(false) + }) + }) +}) + diff --git a/app/web/src/test/features/portfolio/hooks/useSnapshots.test.tsx b/app/web/src/test/features/portfolio/hooks/useSnapshots.test.tsx new file mode 100644 index 0000000..9eddc23 --- /dev/null +++ b/app/web/src/test/features/portfolio/hooks/useSnapshots.test.tsx @@ -0,0 +1,63 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { renderHook, waitFor, act } from '../../../test-utils' +import { useSnapshots } from '@/features/portfolio/hooks/useSnapshots' +import { portfolioApi } from '@/lib/api' + +vi.mock('@/lib/api', () => ({ + portfolioApi: { + getSnapshots: vi.fn(), + }, +})) + +describe('useSnapshots', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('initializes with empty snapshots', () => { + const { result } = renderHook(() => useSnapshots()) + expect(result.current.snapshots).toEqual([]) + expect(result.current.showSkeleton).toBe(false) + expect(result.current.timeRange).toBe('1m') + }) + + it('loads snapshots successfully', async () => { + const mockSnapshots = [ + { asOf: '2024-01-01T00:00:00Z', totalValue: 10000 }, + ] + vi.mocked(portfolioApi.getSnapshots).mockResolvedValue({ series: mockSnapshots } as unknown as Awaited>) + const { result } = renderHook(() => useSnapshots()) + await act(async () => { + await result.current.loadSnapshots() + }) + await waitFor(() => { + expect(result.current.snapshots).toEqual(mockSnapshots) + expect(result.current.showSkeleton).toBe(false) + }, { timeout: 3000 }) + }) + + it('handles loading errors', async () => { + vi.mocked(portfolioApi.getSnapshots).mockRejectedValue(new Error('Error')) + const { result } = renderHook(() => useSnapshots()) + await act(async () => { + await result.current.loadSnapshots() + }) + await waitFor(() => { + expect(result.current.showSkeleton).toBe(false) + }, { timeout: 3000 }) + }) + + it('updates time range', () => { + const { result } = renderHook(() => useSnapshots()) + act(() => { + result.current.setTimeRange('1w') + }) + expect(result.current.timeRange).toBe('1w') + }) + + it('uses custom initial range', () => { + const { result } = renderHook(() => useSnapshots('1y')) + expect(result.current.timeRange).toBe('1y') + }) +}) + diff --git a/app/web/src/test/features/portfolio/utils/calculations.test.ts b/app/web/src/test/features/portfolio/utils/calculations.test.ts new file mode 100644 index 0000000..c12eb76 --- /dev/null +++ b/app/web/src/test/features/portfolio/utils/calculations.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect } from 'vitest' +import { calculateUnrealizedPLPercent, calculateDailyPLPercent } from '@/features/portfolio/utils/calculations' + +describe('Portfolio Calculations', () => { + describe('calculateUnrealizedPLPercent', () => { + it('calculates percentage correctly', () => { + const totals = { + totalValue: 11000, + totalCostBasis: 10000, + unrealizedPL: 1000, + dailyPL: 100, + } + expect(calculateUnrealizedPLPercent(totals)).toBe(10) + }) + + it('handles zero cost basis', () => { + const totals = { + totalValue: 1000, + totalCostBasis: 0, + unrealizedPL: 1000, + dailyPL: 0, + } + expect(calculateUnrealizedPLPercent(totals)).toBe(0) + }) + + it('handles negative unrealized PL', () => { + const totals = { + totalValue: 9000, + totalCostBasis: 10000, + unrealizedPL: -1000, + dailyPL: 0, + } + expect(calculateUnrealizedPLPercent(totals)).toBe(-10) + }) + + it('handles string values', () => { + const totals = { + totalValue: '11000', + totalCostBasis: '10000', + unrealizedPL: '1000', + dailyPL: '100', + } + expect(calculateUnrealizedPLPercent(totals)).toBe(10) + }) + }) + + describe('calculateDailyPLPercent', () => { + it('calculates percentage correctly', () => { + const totals = { + totalValue: 10100, + totalCostBasis: 10000, + unrealizedPL: 100, + dailyPL: 100, + } + expect(calculateDailyPLPercent(totals)).toBeCloseTo(1, 2) + }) + + it('handles zero previous value', () => { + const totals = { + totalValue: 100, + totalCostBasis: 0, + unrealizedPL: 100, + dailyPL: 100, + } + expect(calculateDailyPLPercent(totals)).toBe(0) + }) + + it('handles zero daily PL', () => { + const totals = { + totalValue: 10000, + totalCostBasis: 10000, + unrealizedPL: 0, + dailyPL: 0, + } + expect(calculateDailyPLPercent(totals)).toBe(0) + }) + + it('handles negative daily PL', () => { + const totals = { + totalValue: 9900, + totalCostBasis: 10000, + unrealizedPL: -100, + dailyPL: -100, + } + expect(calculateDailyPLPercent(totals)).toBeLessThan(0) + }) + + it('handles string values', () => { + const totals = { + totalValue: '10100', + totalCostBasis: '10000', + unrealizedPL: '100', + dailyPL: '100', + } + expect(calculateDailyPLPercent(totals)).toBeCloseTo(1, 2) + }) + }) +}) + diff --git a/app/web/src/test/features/portfolio/utils/dateRange.test.ts b/app/web/src/test/features/portfolio/utils/dateRange.test.ts new file mode 100644 index 0000000..53171c1 --- /dev/null +++ b/app/web/src/test/features/portfolio/utils/dateRange.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest' +import { getDateRange } from '@/features/portfolio/utils/dateRange' +import { startOfYear } from 'date-fns' + +describe('getDateRange', () => { + it('returns correct range for 1d', () => { + const result = getDateRange('1d') + expect(result.granularity).toBe('hourly') + expect(result.from).toMatch(/^\d{4}-\d{2}-\d{2}$/) + expect(result.to).toMatch(/^\d{4}-\d{2}-\d{2}$/) + }) + + it('returns correct range for 1w', () => { + const result = getDateRange('1w') + expect(result.granularity).toBe('6hourly') + }) + + it('returns correct range for 1m', () => { + const result = getDateRange('1m') + expect(result.granularity).toBe('daily') + }) + + it('returns correct range for ytd', () => { + const result = getDateRange('ytd') + expect(result.granularity).toBe('daily') + const expectedFrom = startOfYear(new Date()) + expect(result.from).toBe(expectedFrom.toISOString().split('T')[0]) + }) + + it('returns correct range for 1y', () => { + const result = getDateRange('1y') + expect(result.granularity).toBe('weekly') + }) + + it('returns default range for invalid input', () => { + const result = getDateRange('invalid' as '1d' | '1w' | '1m' | 'ytd' | '1y') + expect(result.granularity).toBe('daily') + }) + + it('returns dates in correct format', () => { + const result = getDateRange('1m') + expect(result.from).toMatch(/^\d{4}-\d{2}-\d{2}$/) + expect(result.to).toMatch(/^\d{4}-\d{2}-\d{2}$/) + }) +}) + diff --git a/app/web/src/test/features/portfolio/utils/formatters.test.ts b/app/web/src/test/features/portfolio/utils/formatters.test.ts new file mode 100644 index 0000000..f589f29 --- /dev/null +++ b/app/web/src/test/features/portfolio/utils/formatters.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from 'vitest' +import { formatCurrency, formatPercentage, formatQuantity } from '@/features/portfolio/utils/formatters' + +describe('Portfolio Formatters', () => { + describe('formatCurrency', () => { + it('formats number correctly', () => { + expect(formatCurrency(1000, 'USD')).toBe('$1,000.00') + expect(formatCurrency(1234.56, 'USD')).toBe('$1,234.56') + }) + + it('formats string number correctly', () => { + expect(formatCurrency('1000', 'USD')).toBe('$1,000.00') + expect(formatCurrency('1234.56', 'USD')).toBe('$1,234.56') + }) + + it('handles null and undefined', () => { + expect(formatCurrency(null, 'USD')).toBe('~') + expect(formatCurrency(undefined, 'USD')).toBe('~') + }) + + it('handles invalid values', () => { + expect(formatCurrency('invalid', 'USD')).toBe('~') + expect(formatCurrency(NaN, 'USD')).toBe('~') + }) + + it('uses provided currency', () => { + expect(formatCurrency(1000, 'EUR')).toContain('€') + expect(formatCurrency(1000, 'GBP')).toContain('£') + }) + }) + + describe('formatPercentage', () => { + it('formats positive percentage correctly', () => { + expect(formatPercentage(5.5)).toBe('+5.50%') + expect(formatPercentage(10)).toBe('+10.00%') + }) + + it('formats negative percentage correctly', () => { + expect(formatPercentage(-5.5)).toBe('-5.50%') + expect(formatPercentage(-10)).toBe('-10.00%') + }) + + it('formats string number correctly', () => { + expect(formatPercentage('5.5')).toBe('+5.50%') + expect(formatPercentage('-5.5')).toBe('-5.50%') + }) + + it('handles null and undefined', () => { + expect(formatPercentage(null)).toBe('~') + expect(formatPercentage(undefined)).toBe('~') + }) + + it('handles zero', () => { + expect(formatPercentage(0)).toBe('+0.00%') + }) + }) + + describe('formatQuantity', () => { + it('formats number correctly', () => { + expect(formatQuantity(10)).toBe('10.0000') + expect(formatQuantity(10.1234)).toBe('10.1234') + expect(formatQuantity(10.12345678)).toBe('10.1235') + }) + + it('formats string number correctly', () => { + expect(formatQuantity('10')).toBe('10.0000') + expect(formatQuantity('10.1234')).toBe('10.1234') + }) + + it('handles null and undefined', () => { + expect(formatQuantity(null)).toBe('~') + expect(formatQuantity(undefined)).toBe('~') + }) + + it('handles invalid values', () => { + expect(formatQuantity('invalid')).toBe('~') + expect(formatQuantity(NaN)).toBe('~') + }) + }) +}) + diff --git a/app/web/src/test/features/profile/components/ProfileField.test.tsx b/app/web/src/test/features/profile/components/ProfileField.test.tsx new file mode 100644 index 0000000..c7ee9ff --- /dev/null +++ b/app/web/src/test/features/profile/components/ProfileField.test.tsx @@ -0,0 +1,49 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '../../../test-utils' +import { ProfileField } from '@/features/profile/components/ProfileField' +import { IconUser } from '@tabler/icons-react' + +describe('ProfileField', () => { + it('renders field with label and value', () => { + render(} />) + expect(screen.getByText('Test Label')).toBeInTheDocument() + expect(screen.getByText('Test Value')).toBeInTheDocument() + }) + + it('renders number values as strings', () => { + render(} />) + expect(screen.getByText('25')).toBeInTheDocument() + }) + + it('returns null when value is undefined', () => { + const { container } = render( + } /> + ) + const textContent = container.textContent || '' + expect(textContent).not.toContain('Test') + }) + + it('returns null when value is null', () => { + const { container } = render( + } /> + ) + const textContent = container.textContent || '' + expect(textContent).not.toContain('Test') + }) + + it('returns null when value is empty string', () => { + const { container } = render( + } /> + ) + const textContent = container.textContent || '' + expect(textContent).not.toContain('Test') + }) + + it('renders icon', () => { + const { container } = render( + } /> + ) + expect(container.querySelector('svg')).toBeInTheDocument() + }) +}) + diff --git a/app/web/src/test/features/profile/hooks/useProfile.test.tsx b/app/web/src/test/features/profile/hooks/useProfile.test.tsx new file mode 100644 index 0000000..d18e8e2 --- /dev/null +++ b/app/web/src/test/features/profile/hooks/useProfile.test.tsx @@ -0,0 +1,97 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { renderHook, waitFor, act } from '../../../test-utils' +import { useProfile } from '@/features/profile/hooks/useProfile' +import { usersApi } from '@/lib/api' + +vi.mock('@/lib/api', () => ({ + usersApi: { + getFinancialProfile: vi.fn(), + updateFinancialProfile: vi.fn(), + }, +})) + +describe('useProfile', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('initializes with null profile and loading true', () => { + const { result } = renderHook(() => useProfile()) + expect(result.current.profile).toBeNull() + expect(result.current.loading).toBe(true) + }) + + it('loads profile successfully', async () => { + const mockProfile = { + financialGoals: 'Retirement', + age: 25, + riskTolerance: 'Moderate', + } + vi.mocked(usersApi.getFinancialProfile).mockResolvedValue(mockProfile as unknown as Awaited>) + const { result } = renderHook(() => useProfile()) + await result.current.loadProfile() + await waitFor(() => { + expect(result.current.loading).toBe(false) + }) + expect(result.current.profile).toEqual(mockProfile) + }) + + it('handles loading errors', async () => { + vi.mocked(usersApi.getFinancialProfile).mockRejectedValue(new Error('Error')) + const { result } = renderHook(() => useProfile()) + await expect(result.current.loadProfile()).rejects.toThrow('Error') + await waitFor(() => { + expect(result.current.loading).toBe(false) + }) + }) + + it('updates profile successfully', async () => { + const initialProfile = { age: 25, riskTolerance: 'Moderate' } + vi.mocked(usersApi.getFinancialProfile).mockResolvedValue(initialProfile as unknown as Awaited>) + vi.mocked(usersApi.updateFinancialProfile).mockResolvedValue({ success: true } as unknown as Awaited>) + const { result } = renderHook(() => useProfile()) + await result.current.loadProfile() + await waitFor(() => { + expect(result.current.profile).toEqual(initialProfile) + }) + await act(async () => { + await result.current.updateProfile({ age: 30 }) + }) + await waitFor(() => { + expect(result.current.profile?.age).toBe(30) + }) + expect(usersApi.updateFinancialProfile).toHaveBeenCalledWith({ + ...initialProfile, + age: 30, + }) + }) + + it('handles update errors', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const mockProfile = { age: 25 } + vi.mocked(usersApi.getFinancialProfile).mockResolvedValue(mockProfile as unknown as Awaited>) + vi.mocked(usersApi.updateFinancialProfile).mockRejectedValue(new Error('Update Error')) + const { result } = renderHook(() => useProfile()) + await result.current.loadProfile() + await act(async () => { + try { + await result.current.updateProfile({ age: 30 }) + } catch { + // Expected to throw + } + }) + await waitFor(() => { + expect(consoleErrorSpy).toHaveBeenCalled() + }) + // Profile should not be updated on error + expect(result.current.profile?.age).toBe(25) + consoleErrorSpy.mockRestore() + }) + + it('does not update when profile is null', async () => { + const { result } = renderHook(() => useProfile()) + await result.current.updateProfile({ age: 30 }) + expect(usersApi.updateFinancialProfile).not.toHaveBeenCalled() + }) +}) + diff --git a/app/web/src/test/features/profile/utils/formatters.test.ts b/app/web/src/test/features/profile/utils/formatters.test.ts new file mode 100644 index 0000000..5cc25db --- /dev/null +++ b/app/web/src/test/features/profile/utils/formatters.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from 'vitest' +import { formatExperience, formatIncome, formatInvestmentAmount } from '@/features/profile/utils/formatters' + +describe('Profile Formatters', () => { + describe('formatExperience', () => { + it('formats 0 years correctly', () => { + expect(formatExperience(0)).toBe('Less than 1 year') + }) + + it('formats 1 year correctly', () => { + expect(formatExperience(1)).toBe('1 year') + }) + + it('formats multiple years correctly', () => { + expect(formatExperience(2)).toBe('2 years') + expect(formatExperience(5)).toBe('5 years') + expect(formatExperience(10)).toBe('10 years') + }) + + it('returns undefined for null/undefined', () => { + expect(formatExperience(undefined)).toBeUndefined() + expect(formatExperience(null as unknown as number)).toBeUndefined() + }) + }) + + describe('formatIncome', () => { + it('formats income ranges correctly', () => { + expect(formatIncome('0-25k')).toBe('$0 - $25,000') + expect(formatIncome('25k-50k')).toBe('$25,000 - $50,000') + expect(formatIncome('150k+')).toBe('$150,000+') + }) + + it('returns undefined for empty string', () => { + expect(formatIncome('')).toBeUndefined() + }) + + it('returns undefined for undefined', () => { + expect(formatIncome(undefined)).toBeUndefined() + }) + + it('returns original value for unknown range', () => { + expect(formatIncome('unknown')).toBe('unknown') + }) + }) + + describe('formatInvestmentAmount', () => { + it('formats investment amounts correctly', () => { + expect(formatInvestmentAmount('0-1k')).toBe('$0 - $1,000') + expect(formatInvestmentAmount('1k-5k')).toBe('$1,000 - $5,000') + expect(formatInvestmentAmount('25k+')).toBe('$25,000+') + }) + + it('returns undefined for empty string', () => { + expect(formatInvestmentAmount('')).toBeUndefined() + }) + + it('returns undefined for undefined', () => { + expect(formatInvestmentAmount(undefined)).toBeUndefined() + }) + + it('returns original value for unknown amount', () => { + expect(formatInvestmentAmount('unknown')).toBe('unknown') + }) + }) +}) + diff --git a/app/web/src/test/features/profile/utils/user.test.ts b/app/web/src/test/features/profile/utils/user.test.ts new file mode 100644 index 0000000..ce3f11b --- /dev/null +++ b/app/web/src/test/features/profile/utils/user.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from 'vitest' +import { getUserDisplayName, getUserInitial } from '@/features/profile/utils/user' +import type { User } from '@supabase/supabase-js' + +describe('User Utils', () => { + describe('getUserDisplayName', () => { + it('returns full_name from metadata when available', () => { + const user = { + user_metadata: { full_name: 'John Doe' }, + email: 'john@example.com', + } as unknown as User + expect(getUserDisplayName(user)).toBe('John Doe') + }) + + it('returns email prefix when no full_name', () => { + const user = { + email: 'john@example.com', + } as User + expect(getUserDisplayName(user)).toBe('john') + }) + + it('returns "User" when no email or name', () => { + const user = {} as User + expect(getUserDisplayName(user)).toBe('User') + }) + + it('handles null user', () => { + expect(getUserDisplayName(null)).toBe('User') + }) + + it('handles undefined user', () => { + expect(getUserDisplayName(undefined)).toBe('User') + }) + }) + + describe('getUserInitial', () => { + it('returns uppercase initial from full name', () => { + const user = { + user_metadata: { full_name: 'John Doe' }, + } as unknown as User + expect(getUserInitial(user)).toBe('J') + }) + + it('returns uppercase initial from email', () => { + const user = { + email: 'john@example.com', + } as User + expect(getUserInitial(user)).toBe('J') + }) + + it('returns "U" when no user data', () => { + expect(getUserInitial(null)).toBe('U') + expect(getUserInitial(undefined)).toBe('U') + }) + }) +}) + diff --git a/app/web/src/test/hooks/useGamificationEvents.test.tsx b/app/web/src/test/hooks/useGamificationEvents.test.tsx new file mode 100644 index 0000000..fd6b047 --- /dev/null +++ b/app/web/src/test/hooks/useGamificationEvents.test.tsx @@ -0,0 +1,223 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { waitFor } from '@testing-library/react' +import { renderHook } from '../test-utils' +import { useGamificationEvents } from '@/hooks/useGamificationEvents' +import { gamificationApi } from '@/lib/api' +import type { GamificationEventResponse } from '@/lib/api' + +const mockGamificationContext = { + totalXp: 500, + level: 3, + currentStreak: 5, + xpToNextLevel: 100, + badges: [], + loading: false, + refreshState: vi.fn().mockResolvedValue(undefined), + showXpToast: vi.fn(), + showStreakToast: vi.fn(), + showBadgeModal: vi.fn(), + showLevelUpToast: vi.fn(), +} + +vi.mock('@/contexts/GamificationContext', () => ({ + useGamification: () => mockGamificationContext, +})) + +vi.mock('@/lib/api', () => ({ + gamificationApi: { + sendEvent: vi.fn(), + }, +})) + +describe('useGamificationEvents', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('triggers login event', async () => { + const mockResponse: GamificationEventResponse = { + total_xp: 510, + level: 3, + current_streak: 5, + xp_gained: 10, + level_up: false, + streak_incremented: false, + new_badges: [], + xp_to_next_level: 90, + } + vi.mocked(gamificationApi.sendEvent).mockResolvedValue(mockResponse) + const { result } = renderHook(() => useGamificationEvents()) + await result.current.triggerLogin() + await waitFor(() => { + expect(gamificationApi.sendEvent).toHaveBeenCalledWith({ event_type: 'login' }) + expect(mockGamificationContext.showXpToast).toHaveBeenCalledWith(10, 'login') + expect(mockGamificationContext.refreshState).toHaveBeenCalled() + }) + }) + + it('triggers module completed event', async () => { + const mockResponse: GamificationEventResponse = { + total_xp: 520, + level: 3, + current_streak: 5, + xp_gained: 20, + level_up: false, + streak_incremented: false, + new_badges: [], + xp_to_next_level: 80, + } + vi.mocked(gamificationApi.sendEvent).mockResolvedValue(mockResponse) + const { result } = renderHook(() => useGamificationEvents()) + await result.current.triggerModuleCompleted('module-1', true) + await waitFor(() => { + expect(gamificationApi.sendEvent).toHaveBeenCalledWith({ + event_type: 'module_completed', + module_id: 'module-1', + is_first_time_for_module: true, + }) + expect(mockGamificationContext.showXpToast).toHaveBeenCalledWith(20, 'module_completed') + }) + }) + + it('triggers quiz completed event', async () => { + const mockResponse: GamificationEventResponse = { + total_xp: 530, + level: 3, + current_streak: 6, + xp_gained: 12, + level_up: false, + streak_incremented: true, + new_badges: [], + xp_to_next_level: 70, + } + vi.mocked(gamificationApi.sendEvent).mockResolvedValue(mockResponse) + const { result } = renderHook(() => useGamificationEvents()) + await result.current.triggerQuizCompleted(85) + await waitFor(() => { + expect(gamificationApi.sendEvent).toHaveBeenCalledWith({ + event_type: 'quiz_completed', + quiz_score: 85, + quiz_completed_at: expect.any(String), + }) + expect(mockGamificationContext.showXpToast).toHaveBeenCalledWith(10, 'quiz_completed') + expect(mockGamificationContext.showXpToast).toHaveBeenCalledWith(2, 'streak_bonus') + expect(mockGamificationContext.showStreakToast).toHaveBeenCalledWith(6) + }) + }) + + it('shows level up toast when level up occurs', async () => { + const mockResponse: GamificationEventResponse = { + total_xp: 600, + level: 4, + current_streak: 5, + xp_gained: 100, + level_up: true, + streak_incremented: false, + new_badges: [], + xp_to_next_level: 200, + } + vi.mocked(gamificationApi.sendEvent).mockResolvedValue(mockResponse) + const { result } = renderHook(() => useGamificationEvents()) + await result.current.triggerLogin() + await waitFor(() => { + expect(mockGamificationContext.showLevelUpToast).toHaveBeenCalled() + }) + }) + + it('shows badge modal when badges are earned', async () => { + const mockResponse: GamificationEventResponse = { + total_xp: 550, + level: 3, + current_streak: 5, + xp_gained: 50, + level_up: false, + streak_incremented: false, + new_badges: [ + { code: 'first_quiz', name: 'First Quiz', description: 'Complete your first quiz' }, + ], + xp_to_next_level: 50, + } + vi.mocked(gamificationApi.sendEvent).mockResolvedValue(mockResponse) + const { result } = renderHook(() => useGamificationEvents()) + await result.current.triggerLogin() + await waitFor(() => { + expect(mockGamificationContext.showBadgeModal).toHaveBeenCalledWith([ + { code: 'first_quiz', name: 'First Quiz', description: 'Complete your first quiz' }, + ]) + }) + }) + + it('triggers portfolio position added event', async () => { + const mockResponse: GamificationEventResponse = { + total_xp: 540, + level: 3, + current_streak: 5, + xp_gained: 40, + level_up: false, + streak_incremented: false, + new_badges: [], + xp_to_next_level: 60, + } + vi.mocked(gamificationApi.sendEvent).mockResolvedValue(mockResponse) + const { result } = renderHook(() => useGamificationEvents()) + await result.current.triggerPortfolioPositionAdded('position-1') + await waitFor(() => { + expect(gamificationApi.sendEvent).toHaveBeenCalledWith({ + event_type: 'portfolio_position_added', + portfolio_position_id: 'position-1', + }) + }) + }) + + it('triggers portfolio position updated event', async () => { + const mockResponse: GamificationEventResponse = { + total_xp: 545, + level: 3, + current_streak: 5, + xp_gained: 5, + level_up: false, + streak_incremented: false, + new_badges: [], + xp_to_next_level: 55, + } + vi.mocked(gamificationApi.sendEvent).mockResolvedValue(mockResponse) + const { result } = renderHook(() => useGamificationEvents()) + await result.current.triggerPortfolioPositionUpdated('position-1') + await waitFor(() => { + expect(gamificationApi.sendEvent).toHaveBeenCalledWith({ + event_type: 'portfolio_position_updated', + portfolio_position_id: 'position-1', + }) + }) + }) + + it('handles API errors gracefully', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.mocked(gamificationApi.sendEvent).mockRejectedValue(new Error('API Error')) + const { result } = renderHook(() => useGamificationEvents()) + const response = await result.current.triggerLogin() + expect(response).toBeNull() + expect(consoleErrorSpy).toHaveBeenCalled() + consoleErrorSpy.mockRestore() + }) + + it('does not show XP toast when xp_gained is 0', async () => { + const mockResponse: GamificationEventResponse = { + total_xp: 500, + level: 3, + current_streak: 5, + xp_gained: 0, + level_up: false, + streak_incremented: false, + new_badges: [], + xp_to_next_level: 100, + } + vi.mocked(gamificationApi.sendEvent).mockResolvedValue(mockResponse) + const { result } = renderHook(() => useGamificationEvents()) + await result.current.triggerLogin() + await waitFor(() => { + expect(mockGamificationContext.showXpToast).not.toHaveBeenCalled() + }) + }) +}) + diff --git a/app/web/src/test/lib/api.test.ts b/app/web/src/test/lib/api.test.ts new file mode 100644 index 0000000..70af60a --- /dev/null +++ b/app/web/src/test/lib/api.test.ts @@ -0,0 +1,263 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { portfolioApi, usersApi, modulesApi, gamificationApi } from '@/lib/api' +import { supabase } from '@/lib/supabase' + +vi.mock('@/lib/supabase', () => ({ + supabase: { + auth: { + getSession: vi.fn(), + }, + }, +})) + +global.fetch = vi.fn() + +describe('API Client', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(supabase.auth.getSession).mockResolvedValue({ + data: { + session: { + access_token: 'test-token', + refresh_token: 'refresh-token', + expires_in: 3600, + token_type: 'bearer', + user: { id: '1', email: 'test@example.com' } as unknown as { id: string; email: string }, + } as unknown as { access_token: string; refresh_token: string; expires_in: number; token_type: string; user: { id: string; email: string } }, + }, + error: null, + } as unknown as Awaited>) + }) + + describe('portfolioApi', () => { + it('adds position', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ id: '1', symbol: 'AAPL', quantity: 10, avgCost: 180 }), + } as Response) + const result = await portfolioApi.addPosition({ + symbol: 'AAPL', + quantity: 10, + avgCost: 180, + }) + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('/api/portfolio/positions'), + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: 'Bearer test-token', + }), + }) + ) + expect(result).toEqual({ id: '1', symbol: 'AAPL', quantity: 10, avgCost: 180 }) + }) + + it('gets portfolio', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ holdings: [], totalValue: 0 }), + } as Response) + await portfolioApi.getPortfolio() + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('/api/portfolio'), + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer test-token', + }), + }) + ) + }) + + it('gets snapshots with query params', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ snapshots: [] }), + } as Response) + await portfolioApi.getSnapshots('2024-01-01', '2024-01-31', 'daily') + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('/api/portfolio/snapshots?from=2024-01-01&to=2024-01-31&granularity=daily'), + expect.any(Object) + ) + }) + + it('generates snapshot', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ status: 'success', message: 'Generated', count: 1 }), + } as Response) + await portfolioApi.generateSnapshot('2024-01-01', '2024-01-31') + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('/api/portfolio/snapshots/generate?from=2024-01-01&to=2024-01-31'), + expect.objectContaining({ + method: 'POST', + }) + ) + }) + }) + + describe('usersApi', () => { + it('gets onboarding status', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ completed: true }), + } as Response) + const result = await usersApi.getOnboardingStatus() + expect(result).toEqual({ completed: true }) + }) + + it('gets financial profile', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ risk_tolerance: 'moderate' }), + } as Response) + await usersApi.getFinancialProfile() + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('/api/v1/users/financial-profile'), + expect.any(Object) + ) + }) + + it('updates financial profile', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ success: true }), + } as Response) + await usersApi.updateFinancialProfile({ riskTolerance: 'aggressive' }) + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('/api/v1/users/financial-profile'), + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ riskTolerance: 'aggressive' }), + }) + ) + }) + + it('gets suggestions', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ([]), + } as Response) + await usersApi.getSuggestions() + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('/api/v1/users/suggestions'), + expect.any(Object) + ) + }) + }) + + describe('modulesApi', () => { + it('gets module', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ id: 'module-1', title: 'Test Module' }), + } as Response) + await modulesApi.getModule('module-1') + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('/api/v1/modules/module-1'), + expect.any(Object) + ) + }) + + it('submits attempt', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ success: true }), + } as Response) + await modulesApi.submitAttempt('module-1', 8, 10, true) + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('/api/v1/modules/module-1/attempt'), + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ score: 8, max_score: 10, passed: true }), + }) + ) + }) + }) + + describe('gamificationApi', () => { + it('sends event', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ + total_xp: 500, + level: 3, + current_streak: 5, + xp_gained: 10, + level_up: false, + streak_incremented: false, + new_badges: [], + xp_to_next_level: 100, + }), + } as Response) + await gamificationApi.sendEvent({ event_type: 'login' }) + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('/api/gamification/event'), + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ event_type: 'login' }), + }) + ) + }) + + it('gets state', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ + total_xp: 500, + level: 3, + current_streak: 5, + xp_to_next_level: 100, + badges: [], + }), + } as Response) + await gamificationApi.getState() + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('/api/gamification/me'), + expect.any(Object) + ) + }) + + it('gets badges', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ([]), + } as Response) + await gamificationApi.getBadges() + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('/api/gamification/badges'), + expect.any(Object) + ) + }) + }) + + describe('error handling', () => { + it('throws error when not authenticated', async () => { + vi.mocked(supabase.auth.getSession).mockResolvedValue({ + data: { session: null }, + error: null, + }) + await expect(portfolioApi.getPortfolio()).rejects.toThrow('Not authenticated') + }) + + it('throws error on API error response', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: false, + statusText: 'Bad Request', + json: async () => ({ detail: 'Invalid request' }), + } as Response) + await expect(portfolioApi.getPortfolio()).rejects.toThrow('Invalid request') + }) + + it('handles JSON parse error', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: false, + statusText: 'Internal Server Error', + json: async () => { + throw new Error('Invalid JSON') + }, + } as unknown as Response) + await expect(portfolioApi.getPortfolio()).rejects.toThrow('Internal Server Error') + }) + }) +}) + diff --git a/app/web/src/test/lib/supabase.test.ts b/app/web/src/test/lib/supabase.test.ts new file mode 100644 index 0000000..ddd3264 --- /dev/null +++ b/app/web/src/test/lib/supabase.test.ts @@ -0,0 +1,11 @@ +import { describe, it, expect } from 'vitest' + +describe('supabase', () => { + it('creates supabase client', () => { + // This is a simple test to ensure the module can be imported + // The actual client creation is tested through integration tests + expect(true).toBe(true) + }) +}) + + diff --git a/app/web/src/test/pages/dashboard.test.tsx b/app/web/src/test/pages/dashboard.test.tsx new file mode 100644 index 0000000..83d17e9 --- /dev/null +++ b/app/web/src/test/pages/dashboard.test.tsx @@ -0,0 +1,150 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '../test-utils' +import DashboardPage from '@/pages/dashboard' +import { portfolioApi, usersApi } from '@/lib/api' +import type { PortfolioHoldingsResponse, SnapshotPoint } from '@/types/portfolio' +import type { Suggestion } from '@/types/learning' + +const mockAuthContext = { + user: { id: '1', email: 'test@example.com' }, + session: null, + loading: false, + signUp: vi.fn(), + signIn: vi.fn(), + signInWithGoogle: vi.fn(), + signOut: vi.fn(), +} + +const mockGamificationContext = { + totalXp: 500, + level: 3, + currentStreak: 5, + xpToNextLevel: 100, + badges: [], + loading: false, + refreshState: vi.fn(), + showXpToast: vi.fn(), + showStreakToast: vi.fn(), + showBadgeModal: vi.fn(), + showLevelUpToast: vi.fn(), +} + +vi.mock('@/contexts/AuthContext', async () => { + const actual = await vi.importActual('@/contexts/AuthContext') + return { + ...actual, + useAuth: () => mockAuthContext, + } +}) + +vi.mock('@/contexts/GamificationContext', async () => { + const actual = await vi.importActual('@/contexts/GamificationContext') + return { + ...actual, + useGamification: () => mockGamificationContext, + } +}) + +vi.mock('@/lib/api', () => ({ + portfolioApi: { + getPortfolio: vi.fn(), + getSnapshots: vi.fn(), + }, + usersApi: { + getSuggestions: vi.fn(), + }, +})) + +const mockPortfolio: PortfolioHoldingsResponse = { + positions: [], + totals: { + totalValue: 10000, + totalCostBasis: 9000, + unrealizedPL: 1000, + dailyPL: 100, + }, + baseCurrency: 'USD', + allocationByType: {}, + allocationBySector: {}, + bestMovers: [], + worstMovers: [], +} + +const mockSnapshots: SnapshotPoint[] = [ + { asOf: '2024-01-01T00:00:00Z', totalValue: 10000 }, + { asOf: '2024-01-02T00:00:00Z', totalValue: 10100 }, +] + +const mockSuggestions: Suggestion[] = [ + { + id: '1', + moduleId: 'module-1', + reason: 'Test suggestion', + confidence: 0.8, + status: 'pending', + metadata: null, + }, +] + +describe('Dashboard Page', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(portfolioApi.getPortfolio).mockResolvedValue(mockPortfolio) + vi.mocked(portfolioApi.getSnapshots).mockResolvedValue({ series: mockSnapshots, baseCurrency: 'USD' }) + vi.mocked(usersApi.getSuggestions).mockResolvedValue(mockSuggestions) + }) + + it('renders dashboard skeleton while loading', async () => { + vi.mocked(portfolioApi.getPortfolio).mockImplementation( + () => new Promise(() => {}) // Never resolves + ) + render() + // Should show skeleton initially + expect(document.querySelectorAll('.mantine-Skeleton-root').length).toBeGreaterThan(0) + }) + + it('loads and displays portfolio data', async () => { + render() + await waitFor(() => { + expect(portfolioApi.getPortfolio).toHaveBeenCalled() + expect(portfolioApi.getSnapshots).toHaveBeenCalled() + expect(usersApi.getSuggestions).toHaveBeenCalled() + }, { timeout: 3000 }) + }) + + it('displays dashboard title', async () => { + render() + await waitFor(() => { + expect(screen.getByText('Dashboard')).toBeInTheDocument() + }, { timeout: 3000 }) + }) + + it('displays investments section', async () => { + render() + await waitFor(() => { + expect(screen.getByText('My investments')).toBeInTheDocument() + }, { timeout: 3000 }) + }) + + it('handles API errors gracefully', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.mocked(portfolioApi.getPortfolio).mockRejectedValue(new Error('API Error')) + render() + await waitFor(() => { + expect(consoleErrorSpy).toHaveBeenCalled() + }, { timeout: 3000 }) + consoleErrorSpy.mockRestore() + }) + + it('loads snapshots with correct date range for 1m', async () => { + render() + await waitFor(() => { + expect(portfolioApi.getSnapshots).toHaveBeenCalled() + }, { timeout: 3000 }) + // Check that getSnapshots was called with date parameters + const calls = vi.mocked(portfolioApi.getSnapshots).mock.calls + expect(calls.length).toBeGreaterThan(0) + expect(calls[0][2]).toBe('daily') // granularity for 1m + }) +}) + diff --git a/app/web/src/test/pages/login.test.tsx b/app/web/src/test/pages/login.test.tsx new file mode 100644 index 0000000..852a893 --- /dev/null +++ b/app/web/src/test/pages/login.test.tsx @@ -0,0 +1,146 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '../test-utils' +import userEvent from '@testing-library/user-event' +import Login from '@/pages/login' +import { mockRouter } from '../test-utils' + +const mockAuthContext = { + user: null, + session: null, + loading: false, + signUp: vi.fn(), + signIn: vi.fn(), + signInWithGoogle: vi.fn(), + signOut: vi.fn(), +} + +vi.mock('@/contexts/AuthContext', () => ({ + useAuth: () => mockAuthContext, +})) + +describe('Login Page', () => { + beforeEach(() => { + vi.clearAllMocks() + mockRouter.push = vi.fn() + }) + + it('renders login form', () => { + render() + expect(screen.getAllByText(/sign in/i).length).toBeGreaterThan(0) + expect(screen.getByLabelText(/email/i)).toBeInTheDocument() + expect(screen.getByLabelText(/password/i)).toBeInTheDocument() + const buttons = screen.getAllByRole('button') + const signInButton = buttons.find(btn => btn.textContent?.toLowerCase().includes('sign in')) + expect(signInButton).toBeInTheDocument() + }) + + it('handles email input', async () => { + const user = userEvent.setup() + render() + const emailInput = screen.getByLabelText(/email/i) + await user.type(emailInput, 'test@example.com') + expect(emailInput).toHaveValue('test@example.com') + }) + + it('handles password input', async () => { + const user = userEvent.setup() + render() + const passwordInput = screen.getByLabelText(/password/i) + await user.type(passwordInput, 'password123') + expect(passwordInput).toHaveValue('password123') + }) + + it('submits form with email and password', async () => { + const user = userEvent.setup() + vi.mocked(mockAuthContext.signIn).mockResolvedValue({ error: null }) + render() + const emailInput = screen.getByLabelText(/email/i) + const passwordInput = screen.getByLabelText(/password/i) + const buttons = screen.getAllByRole('button') + const submitButton = buttons.find(btn => btn.textContent?.toLowerCase().includes('sign in')) + expect(submitButton).toBeInTheDocument() + await user.type(emailInput, 'test@example.com') + await user.type(passwordInput, 'password123') + if (submitButton) { + await user.click(submitButton) + await waitFor(() => { + expect(mockAuthContext.signIn).toHaveBeenCalledWith('test@example.com', 'password123') + }) + } + }) + + it('displays error message when login fails', async () => { + const user = userEvent.setup() + vi.mocked(mockAuthContext.signIn).mockResolvedValue({ + error: { message: 'Invalid credentials' } as unknown as { message: string }, + }) + render() + const emailInput = screen.getByLabelText(/email/i) + const passwordInput = screen.getByLabelText(/password/i) + const submitButton = screen.getByRole('button', { name: /sign in/i }) + await user.type(emailInput, 'test@example.com') + await user.type(passwordInput, 'wrongpassword') + await user.click(submitButton) + await waitFor(() => { + expect(screen.getByText(/invalid credentials/i)).toBeInTheDocument() + }) + }) + + it('handles Google sign in', async () => { + const user = userEvent.setup() + vi.mocked(mockAuthContext.signInWithGoogle).mockResolvedValue({ error: null }) + render() + const googleButton = screen.getByRole('button', { name: /continue with google/i }) + await user.click(googleButton) + await waitFor(() => { + expect(mockAuthContext.signInWithGoogle).toHaveBeenCalled() + }) + }) + + it('displays error message when Google sign in fails', async () => { + const user = userEvent.setup() + vi.mocked(mockAuthContext.signInWithGoogle).mockResolvedValue({ + error: { message: 'Google sign in failed' } as unknown as { message: string }, + }) + render() + const googleButton = screen.getByRole('button', { name: /continue with google/i }) + await user.click(googleButton) + await waitFor(() => { + expect(screen.getByText(/google sign in failed/i)).toBeInTheDocument() + }) + }) + + it('shows loading state during sign in', async () => { + const user = userEvent.setup() + let resolveSignIn: (value: { error: null } | { error: { message: string } }) => void + const signInPromise = new Promise<{ error: null } | { error: { message: string } }>(resolve => { + resolveSignIn = resolve + }) + vi.mocked(mockAuthContext.signIn).mockReturnValue(signInPromise) + render() + const emailInput = screen.getByLabelText(/email/i) + const passwordInput = screen.getByLabelText(/password/i) + const buttons = screen.getAllByRole('button') + const submitButton = buttons.find(btn => btn.textContent?.toLowerCase().includes('sign in')) + expect(submitButton).toBeInTheDocument() + if (submitButton) { + await user.type(emailInput, 'test@example.com') + await user.type(passwordInput, 'password123') + await user.click(submitButton) + // Button should be disabled during loading + expect(submitButton).toBeDisabled() + resolveSignIn!({ error: null }) + await waitFor(() => { + expect(submitButton).not.toBeDisabled() + }) + } + }) + + it('has link to signup page', () => { + render() + const signupLink = screen.getByRole('link', { name: /sign up/i }) + expect(signupLink).toBeInTheDocument() + expect(signupLink).toHaveAttribute('href', '/signup') + }) +}) + diff --git a/app/web/src/test/test-utils.tsx b/app/web/src/test/test-utils.tsx index 9422c6f..b610485 100644 --- a/app/web/src/test/test-utils.tsx +++ b/app/web/src/test/test-utils.tsx @@ -1,5 +1,5 @@ import React, { ReactElement } from 'react' -import { render, RenderOptions } from '@testing-library/react' +import { render, RenderOptions, renderHook as rtlRenderHook, RenderHookOptions } from '@testing-library/react' import { MantineProvider } from '@mantine/core' import { vi } from 'vitest' @@ -68,11 +68,20 @@ const customRender = ( options?: Omit, ) => render(ui, { wrapper: AllTheProviders, ...options }) +// Custom renderHook function that includes Mantine provider +const customRenderHook = ( + hook: () => T, + options?: Omit, 'wrapper'>, +) => rtlRenderHook(hook, { wrapper: AllTheProviders, ...options }) + // Re-export everything export * from '@testing-library/react' // Override render method export { customRender as render } +// Override renderHook method +export { customRenderHook as renderHook } + // Export mock router for tests that need to interact with it export { mockRouter } diff --git a/app/web/types/user.ts b/app/web/types/user.ts index 676ad67..d8f073a 100644 --- a/app/web/types/user.ts +++ b/app/web/types/user.ts @@ -5,6 +5,7 @@ export interface UserProfile { annualIncome?: string; investmentAmount?: string; riskTolerance?: string; + country?: string; } export type UpdateProfileRequest = UserProfile; diff --git a/app/web/vitest.config.ts b/app/web/vitest.config.ts index d9a00b7..eb4a0aa 100644 --- a/app/web/vitest.config.ts +++ b/app/web/vitest.config.ts @@ -18,7 +18,7 @@ export default defineConfig({ ], coverage: { provider: 'v8', - reporter: ['text', 'json', 'html'], + reporter: ['text', 'text-summary', 'json', 'html'], exclude: [ 'node_modules/', 'src/test/',