├── backend ├── app │ ├── __init__.py │ ├── db │ │ ├── __init__.py │ │ └── db_config.py │ ├── logs │ │ ├── __init__.py │ │ └── logger.py │ ├── logic │ │ ├── __init__.py │ │ └── document_indexer.py │ ├── models │ │ ├── __init__.py │ │ └── data_structures.py │ ├── controllers │ │ ├── __init__.py │ │ └── chat_router.py │ ├── middleware │ │ ├── rate_limit_middleware.py │ │ └── rate_limiter.py │ ├── startup │ │ └── documents │ │ │ └── init_documents.py │ └── factory.py ├── tests │ ├── __init__.py │ ├── conftest.py │ ├── db │ │ └── test_db_config.py │ ├── controllers │ │ └── test_chat_router.py │ └── logic │ │ └── test_document_indexer.py ├── .dockerignore ├── pytest.ini ├── Pipfile ├── Dockerfile.postgres ├── Dockerfile ├── fly.toml ├── run_server.py ├── docker-compose.yml ├── docs │ └── example_resume.md └── README.md ├── frontend ├── public │ ├── profile.jpg │ ├── about-me.md │ ├── icon.svg │ └── config.json ├── postcss.config.js ├── vercel.json ├── vite.config.js ├── src │ ├── main.jsx │ ├── services │ │ ├── github.js │ │ ├── chatService.js │ │ └── github.test.js │ ├── components │ │ ├── sections │ │ │ ├── Blog │ │ │ │ ├── index.jsx │ │ │ │ └── index.test.jsx │ │ │ ├── Home │ │ │ │ ├── RateLimitCountdown.jsx │ │ │ │ ├── TypingIndicator.jsx │ │ │ │ ├── index.test.jsx │ │ │ │ ├── IntroSection.test.jsx │ │ │ │ ├── index.jsx │ │ │ │ ├── ChatInput.jsx │ │ │ │ ├── ChatMessage.jsx │ │ │ │ ├── RateLimitCountdown.test.jsx │ │ │ │ ├── TypingIndicator.test.jsx │ │ │ │ ├── IntroSection.jsx │ │ │ │ ├── ChatInput.test.jsx │ │ │ │ └── ChatMessage.test.jsx │ │ │ ├── Projects │ │ │ │ ├── index.jsx │ │ │ │ └── index.test.jsx │ │ │ └── About │ │ │ │ ├── index.jsx │ │ │ │ └── index.test.jsx │ │ └── layout │ │ │ ├── MainLayout.jsx │ │ │ ├── NavBar │ │ │ ├── DesktopNav.jsx │ │ │ ├── index.jsx │ │ │ ├── MobileNav.jsx │ │ │ ├── DesktopNav.test.jsx │ │ │ ├── index.test.jsx │ │ │ └── MobileNav.test.jsx │ │ │ ├── shared │ │ │ ├── SocialLinks.jsx │ │ │ └── SocialLinks.test.jsx │ │ │ ├── config │ │ │ ├── navigationConfig.js │ │ │ └── navigationConfig.test.js │ │ │ └── MainLayout.test.jsx │ ├── hooks │ │ ├── useGithubRepos.js │ │ ├── chat │ │ │ ├── useSession.js │ │ │ ├── useMessages.js │ │ │ ├── useSession.test.js │ │ │ └── useMessages.test.js │ │ ├── useTypingEffect.js │ │ ├── useStreamingChat.js │ │ ├── useTypingEffect.test.js │ │ ├── useGithubRepos.test.js │ │ └── useStreamingChat.test.js │ ├── App.jsx │ ├── test │ │ ├── mocks │ │ │ └── config.json │ │ ├── setup.js │ │ ├── mocks.js │ │ └── testUtils.jsx │ ├── config │ │ ├── ConfigProvider.test.jsx │ │ ├── configLoader.js │ │ ├── ConfigProvider.jsx │ │ └── configLoader.test.js │ ├── index.css │ └── App.test.jsx ├── index.html ├── vitest.config.js ├── .dockerignore ├── tailwind.config.js ├── Dockerfile ├── eslint.config.js ├── package.json ├── README.md └── CONFIGURATION.md ├── env.example ├── LICENSE ├── .github └── workflows │ └── run-tests.yml ├── docker-compose.yml ├── README.md └── .gitignore /backend/app/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/app/db/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/app/logs/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/app/logic/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/app/models/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/app/controllers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/tests/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test package for the backend application. 3 | """ -------------------------------------------------------------------------------- /frontend/public/profile.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlonXt/ai-portfolio/HEAD/frontend/public/profile.jpg -------------------------------------------------------------------------------- /backend/.dockerignore: -------------------------------------------------------------------------------- 1 | # Python 2 | __pycache__ 3 | app.egg-info 4 | *.pyc 5 | .mypy_cache 6 | .coverage 7 | htmlcov 8 | .venv 9 | -------------------------------------------------------------------------------- /frontend/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /frontend/vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "rewrites": [ 3 | { 4 | "source": "/(.*)", 5 | "destination": "/index.html" 6 | } 7 | ] 8 | } -------------------------------------------------------------------------------- /frontend/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vite.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }) 8 | -------------------------------------------------------------------------------- /frontend/src/main.jsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import { inject } from '@vercel/analytics' 4 | import './index.css' 5 | import App from './App.jsx' 6 | 7 | // Initialize Vercel analytics 8 | inject(); 9 | 10 | createRoot(document.getElementById('root')).render( 11 | 12 | 13 | , 14 | ) 15 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | alontrugman.dev 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /backend/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | testpaths = tests 3 | python_files = test_*.py 4 | python_classes = Test* 5 | python_functions = test_* 6 | addopts = -v --cov=app --cov-report=term-missing -W ignore::DeprecationWarning -W ignore::RuntimeWarning 7 | pythonpath = . 8 | asyncio_mode = auto 9 | asyncio_default_fixture_loop_scope = function 10 | markers = 11 | unit: mark test as a unit test 12 | integration: mark test as an integration test -------------------------------------------------------------------------------- /frontend/vitest.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config' 2 | import react from '@vitejs/plugin-react' 3 | import { resolve } from 'path' 4 | 5 | export default defineConfig({ 6 | plugins: [react()], 7 | test: { 8 | environment: 'jsdom', 9 | globals: true, 10 | setupFiles: ['./src/test/setup.js'], 11 | }, 12 | resolve: { 13 | alias: { 14 | '@': resolve(__dirname, './src'), 15 | }, 16 | }, 17 | }) -------------------------------------------------------------------------------- /frontend/src/services/github.js: -------------------------------------------------------------------------------- 1 | const GITHUB_API_BASE = 'https://api.github.com' 2 | 3 | export const fetchUserRepos = async (username) => { 4 | try { 5 | const response = await fetch(`${GITHUB_API_BASE}/users/${username}/repos`) 6 | if (!response.ok) throw new Error('Failed to fetch repos') 7 | return await response.json() 8 | } catch (error) { 9 | console.error('Error fetching repos:', error) 10 | throw error 11 | } 12 | } -------------------------------------------------------------------------------- /backend/app/db/db_config.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import quote_plus 2 | from pydantic import BaseModel 3 | 4 | 5 | class PostgresConfig(BaseModel): 6 | server: str = "localhost" 7 | port: int = 5432 8 | db_name: str 9 | user: str 10 | password: str 11 | 12 | def get_connection_url(self) -> str: 13 | encoded_password = quote_plus(self.password) 14 | return f"postgresql://{self.user}:{encoded_password}@{self.server}:{self.port}/{self.db_name}" -------------------------------------------------------------------------------- /frontend/src/components/sections/Blog/index.jsx: -------------------------------------------------------------------------------- 1 | import { motion } from 'framer-motion' 2 | 3 | export const BlogSection = () => { 4 | return ( 5 | 11 |

Blog

12 |

Coming Soon!

13 |
14 | ) 15 | } -------------------------------------------------------------------------------- /frontend/src/components/layout/MainLayout.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types' 2 | import { NavBar } from './NavBar' 3 | 4 | export const MainLayout = ({ children }) => { 5 | return ( 6 |
7 |
8 | 9 |
10 | {children} 11 |
12 |
13 | ) 14 | } 15 | 16 | MainLayout.propTypes = { 17 | children: PropTypes.node.isRequired 18 | } -------------------------------------------------------------------------------- /frontend/.dockerignore: -------------------------------------------------------------------------------- 1 | # Python 2 | __pycache__ 3 | app.egg-info 4 | *.pyc 5 | .mypy_cache 6 | .coverage 7 | htmlcov 8 | .venv 9 | 10 | # Dependencies 11 | node_modules 12 | npm-debug.log 13 | yarn-debug.log 14 | yarn-error.log 15 | 16 | # Testing 17 | coverage 18 | .nyc_output 19 | 20 | # Build 21 | dist 22 | build 23 | 24 | # Environment 25 | .env 26 | .env.local 27 | .env.*.local 28 | 29 | # Editor directories and files 30 | .idea 31 | .vscode 32 | *.suo 33 | *.ntvs* 34 | *.njsproj 35 | *.sln 36 | *.sw? 37 | 38 | # System Files 39 | .DS_Store 40 | Thumbs.db 41 | -------------------------------------------------------------------------------- /frontend/tailwind.config.js: -------------------------------------------------------------------------------- 1 | import plugin from 'tailwindcss/plugin' 2 | 3 | /** @type {import('tailwindcss').Config} */ 4 | export default { 5 | content: [ 6 | "./index.html", 7 | "./src/**/*.{js,ts,jsx,tsx}", 8 | ], 9 | theme: { 10 | extend: {}, 11 | }, 12 | plugins: [ 13 | plugin(function({ addUtilities }) { 14 | addUtilities({ 15 | '.bg-grid-pattern': { 16 | 'background-image': 'radial-gradient(rgba(255, 255, 255, 0.1) 1px, transparent 1px)', 17 | 'background-size': '20px 20px', 18 | }, 19 | }) 20 | }), 21 | ], 22 | } 23 | -------------------------------------------------------------------------------- /backend/tests/conftest.py: -------------------------------------------------------------------------------- 1 | """ 2 | Pytest configuration file containing shared fixtures. 3 | """ 4 | import pytest 5 | from unittest.mock import Mock 6 | from app.logic.chat_service import ChatService 7 | from app.controllers.chat_router import ChatRouter 8 | 9 | @pytest.fixture 10 | def mock_chat_service(): 11 | """ 12 | Creates a mock ChatService for testing. 13 | """ 14 | mock = Mock(spec=ChatService) 15 | return mock 16 | 17 | @pytest.fixture 18 | def chat_router(mock_chat_service): 19 | """ 20 | Creates a ChatRouter instance with mock dependencies. 21 | """ 22 | return ChatRouter(chat_service=mock_chat_service) -------------------------------------------------------------------------------- /backend/Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | fastapi = "==0.115.8" 8 | uvicorn = "==0.34.0" 9 | pydantic = "==2.10.6" 10 | openai = "==1.64.0" 11 | sqlmodel = "==0.0.22" 12 | psycopg2-binary = "==2.9.10" 13 | python-dotenv = "==1.0.1" 14 | pgvector = "==0.3.6" 15 | numpy = "==2.2.3" 16 | langchain-openai = "==0.3.6" 17 | langchain-community = "==0.3.18" 18 | redis = "==5.2.1" 19 | slowapi = "==0.1.9" 20 | 21 | [dev-packages] 22 | pytest = "==8.3.4" 23 | pytest-asyncio = "==0.25.3" 24 | pytest-cov = "==6.0.0" 25 | 26 | [requires] 27 | python_version = "3.12" 28 | python_full_version = "3.12.5" 29 | -------------------------------------------------------------------------------- /backend/Dockerfile.postgres: -------------------------------------------------------------------------------- 1 | FROM postgres:16.1 2 | 3 | RUN apt-get update && \ 4 | apt-get install -y --no-install-recommends \ 5 | git \ 6 | build-essential \ 7 | postgresql-server-dev-16 \ 8 | ca-certificates && \ 9 | update-ca-certificates 10 | 11 | WORKDIR /home 12 | RUN git clone --branch v0.7.4 https://github.com/pgvector/pgvector.git && \ 13 | cd pgvector && \ 14 | make && \ 15 | make install 16 | 17 | # Cleanup to reduce image size 18 | RUN apt-get remove -y git build-essential postgresql-server-dev-16 && \ 19 | apt-get autoremove -y && \ 20 | apt-get clean && \ 21 | rm -rf /var/lib/apt/lists/* /home/pgvector -------------------------------------------------------------------------------- /frontend/src/components/sections/Blog/index.test.jsx: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { screen } from '@testing-library/react'; 3 | import { BlogSection } from './index'; 4 | import React from 'react'; 5 | import '../../../test/mocks'; 6 | import { renderWithConfig } from '../../../test/testUtils'; 7 | 8 | describe('BlogSection', () => { 9 | it('renders the blog section with coming soon message', () => { 10 | renderWithConfig(); 11 | 12 | // Check for heading and coming soon message 13 | expect(screen.getByText('Blog')).toBeInTheDocument(); 14 | expect(screen.getByText('Coming Soon!')).toBeInTheDocument(); 15 | }); 16 | }); -------------------------------------------------------------------------------- /frontend/public/about-me.md: -------------------------------------------------------------------------------- 1 | Hi, I'm Alon Trugman 👋 As a software developer with a passion for startups, I thrive when turning ideas into impactful products. 2 | At Wix, I improved developer experience & securtiy through Git infrastructure solutions. 3 | Previously at Planck (now Applied Systems), I built scalable data processing systems that transformed insurance industry data. 4 | My background in Industrial Engineering and Data Science brings an analytical perspective to engineering challenges. 5 | I specialize in modern tech stacks and excel at simplifying complex problems. 6 | Outside work, I train for triathlons, practice Vipassana meditation, and mentor developers through Kravi-Tech & Genesis. 7 | 8 | Tech enthusiast • People Person • Sports Junkie • Meditator -------------------------------------------------------------------------------- /frontend/public/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /backend/app/logs/logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | from functools import lru_cache 4 | 5 | FORMATTER = logging.Formatter("%(asctime)s - %(levelname)s - %(name)s - %(message)s") 6 | 7 | @lru_cache(maxsize=None) 8 | def get_console_handler() -> logging.StreamHandler: 9 | """Configure and cache console logging handler""" 10 | console_handler = logging.StreamHandler(sys.stdout) 11 | console_handler.setFormatter(FORMATTER) 12 | return console_handler 13 | 14 | def get_logger(logger_name: str) -> logging.Logger: 15 | logger = logging.getLogger(logger_name) 16 | logger.setLevel(logging.DEBUG) 17 | 18 | if not logger.handlers: 19 | logger.addHandler(get_console_handler()) 20 | 21 | logger.propagate = False 22 | return logger 23 | 24 | -------------------------------------------------------------------------------- /backend/app/middleware/rate_limit_middleware.py: -------------------------------------------------------------------------------- 1 | from fastapi import Request 2 | from starlette.middleware.base import BaseHTTPMiddleware 3 | from app.middleware.rate_limiter import RateLimiter 4 | 5 | class RateLimitMiddleware(BaseHTTPMiddleware): 6 | def __init__(self, app, rate_limiter: RateLimiter): 7 | super().__init__(app) 8 | self.rate_limiter = rate_limiter 9 | 10 | async def dispatch(self, request: Request, call_next): 11 | # Check rate limits 12 | rate_limit_response = await self.rate_limiter.check_rate_limit(request, None) 13 | if rate_limit_response: 14 | return rate_limit_response 15 | 16 | # Proceed with the request if within limits 17 | response = await call_next(request) 18 | return response -------------------------------------------------------------------------------- /frontend/src/components/sections/Home/RateLimitCountdown.jsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import { Clock } from 'lucide-react'; 3 | 4 | export const RateLimitCountdown = ({ seconds }) => { 5 | const [timeLeft, setTimeLeft] = useState(seconds); 6 | 7 | useEffect(() => { 8 | if (timeLeft <= 0) return; 9 | 10 | const timer = setInterval(() => { 11 | setTimeLeft(prev => Math.max(0, prev - 1)); 12 | }, 1000); 13 | 14 | return () => clearInterval(timer); 15 | }, [timeLeft]); 16 | 17 | if (timeLeft <= 0) return null; 18 | 19 | return ( 20 |
21 | 22 | Try again in {timeLeft} seconds 23 |
24 | ); 25 | }; -------------------------------------------------------------------------------- /frontend/src/hooks/useGithubRepos.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react' 2 | import { fetchUserRepos } from '../services/github' 3 | 4 | export const useGithubRepos = (username, limit = 6) => { 5 | const [repos, setRepos] = useState([]) 6 | const [loading, setLoading] = useState(true) 7 | const [error, setError] = useState(null) 8 | 9 | useEffect(() => { 10 | const loadRepos = async () => { 11 | try { 12 | const data = await fetchUserRepos(username) 13 | setRepos(data.filter(repo => !repo.fork).slice(0, limit)) 14 | } catch (err) { 15 | setError(err) 16 | } finally { 17 | setLoading(false) 18 | } 19 | } 20 | 21 | loadRepos() 22 | }, [username, limit]) 23 | 24 | return { repos, loading, error } 25 | } -------------------------------------------------------------------------------- /env.example: -------------------------------------------------------------------------------- 1 | # Backend 2 | UVICORN_IP=0.0.0.0 3 | UVICORN_PORT=8000 4 | # I'm using https://requesty.ai/ for an LLM router but you can use the default: https://api.openai.com/v1 5 | LLM_ROUTER_URL="https://router.requesty.ai/v1" 6 | LLM_ROUTER_API_KEY= 7 | # Change this to an OpenAI model if not using requesty.ai 8 | LLM_MODEL="anthropic/claude-3-5-sonnet-latest" 9 | EMBEDDING_MODEL="text-embedding-3-small" 10 | OPENAI_API_KEY= 11 | FRONTEND_URL=http://localhost:5173 12 | 13 | # Postgres 14 | POSTGRES_SERVER=localhost 15 | POSTGRES_PORT=5432 16 | POSTGRES_DB=pgdb 17 | POSTGRES_USER=pguser 18 | POSTGRES_PASSWORD=docker 19 | 20 | # Redis and Rate Limiting 21 | REDIS_URL=redis://localhost:6379 22 | GLOBAL_RATE_LIMIT=1000/hour 23 | CHAT_RATE_LIMIT=30/minute 24 | 25 | # Frontend 26 | VITE_BACKEND_URL=http://localhost:8000 -------------------------------------------------------------------------------- /frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | # Build stage 2 | FROM node:20-slim AS builder 3 | 4 | # Set working directory 5 | WORKDIR /app 6 | 7 | # Copy package files 8 | COPY package*.json ./ 9 | 10 | # Install dependencies 11 | RUN npm ci 12 | 13 | # Copy the rest of the application 14 | COPY . . 15 | 16 | # Build the application 17 | RUN npm run build 18 | 19 | # Production stage 20 | FROM nginx:alpine 21 | 22 | # Copy custom nginx config to handle SPA routing 23 | RUN echo 'server { \ 24 | listen 80; \ 25 | location / { \ 26 | root /usr/share/nginx/html; \ 27 | index index.html; \ 28 | try_files $uri $uri/ /index.html; \ 29 | } \ 30 | }' > /etc/nginx/conf.d/default.conf 31 | 32 | # Copy built assets from build stage 33 | COPY --from=builder /app/dist /usr/share/nginx/html 34 | 35 | # Expose port 80 36 | EXPOSE 80 37 | 38 | # Start nginx 39 | CMD ["nginx", "-g", "daemon off;"] -------------------------------------------------------------------------------- /frontend/src/hooks/chat/useSession.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import { v4 as uuidv4 } from 'uuid'; 3 | 4 | export const useSession = () => { 5 | const [sessionId, setSessionId] = useState(''); 6 | 7 | useEffect(() => { 8 | initializeSession(); 9 | }, []); 10 | 11 | const initializeSession = () => { 12 | const existingSessionId = localStorage.getItem('chatSessionId'); 13 | if (existingSessionId) { 14 | setSessionId(existingSessionId); 15 | } else { 16 | const newSessionId = uuidv4(); 17 | localStorage.setItem('chatSessionId', newSessionId); 18 | setSessionId(newSessionId); 19 | } 20 | }; 21 | 22 | const startNewSession = () => { 23 | const newSessionId = uuidv4(); 24 | localStorage.setItem('chatSessionId', newSessionId); 25 | setSessionId(newSessionId); 26 | return newSessionId; 27 | }; 28 | 29 | return { sessionId, startNewSession }; 30 | }; -------------------------------------------------------------------------------- /backend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.12-slim 2 | 3 | # Set environment variables 4 | ENV PYTHONUNBUFFERED=1 \ 5 | PYTHONDONTWRITEBYTECODE=1 \ 6 | PIP_NO_CACHE_DIR=off \ 7 | PIP_DISABLE_PIP_VERSION_CHECK=on \ 8 | PIP_DEFAULT_TIMEOUT=100 9 | 10 | # Set working directory 11 | WORKDIR /app 12 | 13 | # Install system dependencies 14 | RUN apt-get update \ 15 | && apt-get install -y --no-install-recommends \ 16 | curl \ 17 | build-essential \ 18 | libpq-dev \ 19 | && apt-get clean \ 20 | && rm -rf /var/lib/apt/lists/* 21 | 22 | # Install pipenv 23 | RUN pip install --no-cache-dir pipenv 24 | 25 | # Copy Pipfile and Pipfile.lock 26 | COPY Pipfile* ./ 27 | 28 | # Install dependencies 29 | RUN pipenv install --system --deploy 30 | 31 | # Copy the rest of the application 32 | COPY . . 33 | 34 | # Expose the port the app runs on 35 | EXPOSE 8000 36 | 37 | # Command to run the application 38 | CMD ["python", "-m", "run_server"] -------------------------------------------------------------------------------- /frontend/src/hooks/useTypingEffect.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react' 2 | 3 | export const useTypingEffect = (content, speed = 30) => { 4 | const [displayedContent, setDisplayedContent] = useState('') 5 | const [isTyping, setIsTyping] = useState(true) 6 | 7 | useEffect(() => { 8 | if (!content) { 9 | setDisplayedContent('') 10 | setIsTyping(true) 11 | return 12 | } 13 | 14 | if (!isTyping) return 15 | 16 | let currentIndex = 0 17 | const textLength = content.length 18 | 19 | const typingInterval = setInterval(() => { 20 | currentIndex++ 21 | if (currentIndex <= textLength) { 22 | setDisplayedContent(content.slice(0, currentIndex)) 23 | } else { 24 | clearInterval(typingInterval) 25 | setIsTyping(false) 26 | } 27 | }, speed) 28 | 29 | return () => clearInterval(typingInterval) 30 | }, [content, isTyping, speed]) 31 | 32 | return { displayedContent, isTyping, setIsTyping, setDisplayedContent } 33 | } -------------------------------------------------------------------------------- /backend/fly.toml: -------------------------------------------------------------------------------- 1 | # fly.toml app configuration file generated for first-production on 2025-01-19T15:07:05+02:00 2 | # 3 | # See https://fly.io/docs/reference/configuration/ for information about how to use this file. 4 | # 5 | 6 | app = 'example-portfolio' 7 | primary_region = 'fra' 8 | 9 | [build] 10 | 11 | [env] 12 | LLM_MODEL = 'anthropic/claude-3-5-sonnet-latest' 13 | LLM_ROUTER_URL = 'https://router.requesty.ai/v1' 14 | UVICORN_IP = '0.0.0.0' 15 | UVICORN_PORT = '8000' 16 | POSTGRES_SERVER="example-postgres-with-pg-vector.flycast" 17 | POSTGRES_PORT="5432" 18 | POSTGRES_DB="postgres" 19 | POSTGRES_USER="postgres" 20 | FRONTEND_URL='https://www.example.com' 21 | EMBEDDING_MODEL="text-embedding-3-small" 22 | GLOBAL_RATE_LIMIT="1000/hour" 23 | CHAT_RATE_LIMIT="30/minute" 24 | 25 | [http_service] 26 | internal_port = 8000 27 | force_https = true 28 | auto_stop_machines = 'stop' 29 | auto_start_machines = true 30 | min_machines_running = 1 31 | processes = ['app'] 32 | 33 | [[vm]] 34 | memory = '512mb' 35 | cpu_kind = 'shared' 36 | cpus = 1 37 | -------------------------------------------------------------------------------- /frontend/src/components/layout/NavBar/DesktopNav.jsx: -------------------------------------------------------------------------------- 1 | import { NavLink } from 'react-router-dom' 2 | import { sections } from '../config/navigationConfig' 3 | import { SocialLinks } from '../shared/SocialLinks' 4 | 5 | export const DesktopNav = () => { 6 | return ( 7 |
8 | {/* Navigation Links */} 9 |
10 | {Object.entries(sections).map(([key, { icon: Icon, title, path }]) => ( 11 | ` 15 | px-4 py-2 rounded-lg flex items-center space-x-2 transition-colors 16 | ${isActive 17 | ? 'bg-white/10 text-white' 18 | : 'text-gray-400 hover:text-[#00C8DC] hover:bg-white/5'} 19 | `} 20 | > 21 | 22 | {title} 23 | 24 | ))} 25 |
26 | 27 | {/* Social Links */} 28 | 29 |
30 | ) 31 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Alon Trugman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /frontend/src/App.jsx: -------------------------------------------------------------------------------- 1 | import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' 2 | import { MainLayout } from './components/layout/MainLayout' 3 | import { HomeSection } from './components/sections/Home' 4 | import { AboutSection } from './components/sections/About' 5 | import { ProjectsSection } from './components/sections/Projects' 6 | import { BlogSection } from './components/sections/Blog' 7 | import { ConfigProvider } from './config/ConfigProvider' 8 | 9 | const App = () => { 10 | return ( 11 | 12 | 13 | 14 | 15 | } /> 16 | } /> 17 | } /> 18 | } /> 19 | } /> 20 | } /> 21 | 22 | 23 | 24 | 25 | ) 26 | } 27 | 28 | export default App -------------------------------------------------------------------------------- /frontend/eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import react from 'eslint-plugin-react' 4 | import reactHooks from 'eslint-plugin-react-hooks' 5 | import reactRefresh from 'eslint-plugin-react-refresh' 6 | 7 | export default [ 8 | { ignores: ['dist'] }, 9 | { 10 | files: ['**/*.{js,jsx}'], 11 | languageOptions: { 12 | ecmaVersion: 2020, 13 | globals: globals.browser, 14 | parserOptions: { 15 | ecmaVersion: 'latest', 16 | ecmaFeatures: { jsx: true }, 17 | sourceType: 'module', 18 | }, 19 | }, 20 | settings: { react: { version: '18.3' } }, 21 | plugins: { 22 | react, 23 | 'react-hooks': reactHooks, 24 | 'react-refresh': reactRefresh, 25 | }, 26 | rules: { 27 | ...js.configs.recommended.rules, 28 | ...react.configs.recommended.rules, 29 | ...react.configs['jsx-runtime'].rules, 30 | ...reactHooks.configs.recommended.rules, 31 | 'react/jsx-no-target-blank': 'off', 32 | 'react-refresh/only-export-components': [ 33 | 'warn', 34 | { allowConstantExport: true }, 35 | ], 36 | }, 37 | }, 38 | ] 39 | -------------------------------------------------------------------------------- /frontend/src/components/layout/shared/SocialLinks.jsx: -------------------------------------------------------------------------------- 1 | import { motion } from 'framer-motion' 2 | import { getSocialLinksWithIcons, navConfig } from '../config/navigationConfig' 3 | 4 | export const SocialLinks = ({ isMobile = false }) => { 5 | // Initialize socialLinks directly instead of using useState + useEffect 6 | const socialLinks = getSocialLinksWithIcons(); 7 | 8 | return ( 9 |
10 | {socialLinks.map(({ href, icon: Icon, label, color }) => ( 11 | e.stopPropagation()} 23 | > 24 | 25 | {isMobile && {label}} 26 | 27 | ))} 28 |
29 | ) 30 | } -------------------------------------------------------------------------------- /frontend/src/test/mocks/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "personal": { 3 | "name": "Test User", 4 | "email": "test@example.com", 5 | "profileImageAlt": "Test User" 6 | }, 7 | "social": { 8 | "github": { 9 | "url": "https://github.com/testuser" 10 | }, 11 | "linkedin": { 12 | "url": "https://linkedin.com/in/alon-trugman" 13 | }, 14 | "email": { 15 | "url": "mailto:alon.xt@gmail.com" 16 | } 17 | }, 18 | 19 | "content": { 20 | "intro": { 21 | "cards": [ 22 | { 23 | "title": "Years of Growth", 24 | "description": "Documenting my journey from 2.5 years of therapy", 25 | "icon": "Brain" 26 | }, 27 | { 28 | "title": "Knowledge Collection", 29 | "description": "My insights from books, podcasts, blogs and social media posts", 30 | "icon": "BookOpen" 31 | }, 32 | { 33 | "title": "AI Conversations", 34 | "description": "Based on my real experince and content", 35 | "icon": "MessageSquareText" 36 | } 37 | ], 38 | "paragraphs": [ 39 | "Test paragraph", 40 | "Test paragraph 2" 41 | ] 42 | } 43 | }, 44 | 45 | "chat": { 46 | "inputPlaceholder": "Ask me anything about Alon...", 47 | "initialMessage": "Hello!" 48 | } 49 | } -------------------------------------------------------------------------------- /.github/workflows/run-tests.yml: -------------------------------------------------------------------------------- 1 | name: Run Tests 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | push: 7 | branches: [main] 8 | 9 | jobs: 10 | backend-tests: 11 | name: Backend Tests 12 | runs-on: ubuntu-latest 13 | defaults: 14 | run: 15 | working-directory: ./backend 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | 20 | - name: Set up Python 21 | uses: actions/setup-python@v5 22 | with: 23 | python-version: '3.12.5' 24 | 25 | - name: Install pipenv 26 | run: pip install pipenv 27 | 28 | - name: Install dependencies 29 | run: pipenv install --dev 30 | 31 | - name: Run tests 32 | run: pipenv run pytest 33 | 34 | frontend-tests: 35 | name: Frontend Tests 36 | runs-on: ubuntu-latest 37 | defaults: 38 | run: 39 | working-directory: ./frontend 40 | 41 | steps: 42 | - uses: actions/checkout@v4 43 | 44 | - name: Set up Node.js 45 | uses: actions/setup-node@v4 46 | with: 47 | node-version: '20' 48 | cache: 'npm' 49 | cache-dependency-path: './frontend/package-lock.json' 50 | 51 | - name: Install dependencies 52 | run: npm ci 53 | 54 | - name: Run tests 55 | run: npm test -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview", 11 | "test": "vitest run", 12 | "test:watch": "vitest" 13 | }, 14 | "dependencies": { 15 | "@vercel/analytics": "1.4.1", 16 | "framer-motion": "11.18.1", 17 | "lucide-react": "0.473.0", 18 | "react": "18.3.1", 19 | "react-dom": "18.3.1", 20 | "react-markdown": "9.0.3", 21 | "react-router-dom": "7.1.3", 22 | "uuid": "11.0.5" 23 | }, 24 | "devDependencies": { 25 | "@eslint/js": "9.17.0", 26 | "@testing-library/jest-dom": "6.6.3", 27 | "@testing-library/react": "16.2.0", 28 | "@types/react": "18.3.18", 29 | "@types/react-dom": "18.3.5", 30 | "@vitejs/plugin-react": "4.3.4", 31 | "autoprefixer": "10.4.20", 32 | "eslint": "9.17.0", 33 | "eslint-plugin-react": "7.37.2", 34 | "eslint-plugin-react-hooks": "5.0.0", 35 | "eslint-plugin-react-refresh": "0.4.16", 36 | "globals": "15.14.0", 37 | "jsdom": "26.0.0", 38 | "postcss": "8.5.1", 39 | "tailwind-scrollbar": "3.0.4", 40 | "tailwindcss": "3.4.17", 41 | "vite": "6.0.5", 42 | "vitest": "3.0.7" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /backend/app/logic/document_indexer.py: -------------------------------------------------------------------------------- 1 | from langchain.text_splitter import MarkdownTextSplitter 2 | from langchain_openai import OpenAIEmbeddings 3 | from typing import List 4 | import hashlib 5 | 6 | class DocumentIndexer: 7 | def __init__(self, embeddings: OpenAIEmbeddings): 8 | self.embeddings = embeddings 9 | self.text_splitter = MarkdownTextSplitter( 10 | chunk_size=1000, 11 | chunk_overlap=200 12 | ) 13 | 14 | def calculate_content_hash(self, content: str) -> str: 15 | """Calculate MD5 hash of document content""" 16 | return hashlib.md5(content.encode('utf-8')).hexdigest() 17 | 18 | def process_markdown(self, file_path: str) -> List[dict]: 19 | """Process markdown file into chunks with embeddings""" 20 | with open(file_path, 'r') as file: 21 | content = file.read() 22 | 23 | content_hash = self.calculate_content_hash(content) 24 | chunks = self.text_splitter.split_text(content) 25 | embeddings = self.embeddings.embed_documents(chunks) 26 | 27 | return [{ 28 | 'content': chunk, 29 | 'embedding': embedding, 30 | 'metadata': { 31 | 'source': file_path, 32 | 'content_hash': content_hash 33 | } 34 | } for chunk, embedding in zip(chunks, embeddings)] -------------------------------------------------------------------------------- /backend/run_server.py: -------------------------------------------------------------------------------- 1 | import os 2 | from app import factory 3 | from app.controllers.chat_router import ChatRouter 4 | from app.logs.logger import get_logger 5 | from app.startup.documents.init_documents import init_documents 6 | from dotenv import load_dotenv 7 | import uvicorn 8 | import asyncio 9 | 10 | logger = get_logger(__name__) 11 | 12 | async def initialize_services(): 13 | """Initialize all required services and data""" 14 | document_indexer = factory.document_indexer() 15 | db_handler = factory.database_handler() 16 | await init_documents(document_indexer, db_handler) 17 | 18 | async def create_application(): 19 | """Create and configure the FastAPI application""" 20 | app = factory.create_app() 21 | chat_service = factory.chat_service() 22 | router = ChatRouter(chat_service=chat_service) 23 | app.include_router(router=router.router) 24 | 25 | return app 26 | 27 | async def run_server(): 28 | """Main function to start the server""" 29 | load_dotenv() 30 | 31 | app = await create_application() 32 | await initialize_services() 33 | 34 | logger.info("Starting the server") 35 | config = uvicorn.Config( 36 | app=app, 37 | host=os.getenv("UVICORN_IP"), 38 | port=int(os.getenv("UVICORN_PORT")) 39 | ) 40 | server = uvicorn.Server(config) 41 | await server.serve() 42 | 43 | if __name__ == '__main__': 44 | asyncio.run(run_server()) 45 | -------------------------------------------------------------------------------- /frontend/src/config/ConfigProvider.test.jsx: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi } from 'vitest'; 2 | import { render, screen, waitFor } from '@testing-library/react'; 3 | import React from 'react'; 4 | import mockConfig from '../test/mocks/config.json'; 5 | import { ConfigProvider, useConfig } from './ConfigProvider'; 6 | 7 | // Mock the configLoader module 8 | vi.mock('./configLoader', () => ({ 9 | loadConfig: vi.fn().mockResolvedValue(mockConfig) 10 | })); 11 | 12 | describe('ConfigProvider', () => { 13 | it('provides config to children when loading succeeds', async () => { 14 | // Define a test component that uses the useConfig hook 15 | const TestComponent = () => { 16 | const { config, loading, error } = useConfig(); 17 | 18 | if (loading) return
Loading...
; 19 | if (error) return
Error: {error}
; 20 | 21 | return ( 22 |
23 |
{config.personal.name}
24 |
{config.personal.email}
25 |
26 | ); 27 | }; 28 | 29 | render( 30 | 31 | 32 | 33 | ); 34 | 35 | // Wait for the config to be loaded and displayed 36 | await waitFor(() => { 37 | expect(screen.getByTestId('name')).toHaveTextContent(mockConfig.personal.name); 38 | expect(screen.getByTestId('email')).toHaveTextContent(mockConfig.personal.email); 39 | }); 40 | }); 41 | }); -------------------------------------------------------------------------------- /frontend/src/hooks/chat/useMessages.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import { getChatConfig } from '../../config/configLoader'; 3 | 4 | export const useMessages = () => { 5 | const [initialMessage, setInitialMessage] = useState({ 6 | role: 'assistant', 7 | content: 'Loading...' 8 | }); 9 | 10 | const [messages, setMessages] = useState([]); 11 | 12 | // Load the initial message from the configuration 13 | useEffect(() => { 14 | const chatConfig = getChatConfig(); 15 | const initialMessage = chatConfig?.initialMessage || 'Hello! How can I help you today?'; 16 | 17 | const message = { 18 | role: 'assistant', 19 | content: initialMessage 20 | }; 21 | 22 | setInitialMessage(message); 23 | setMessages([message]); 24 | }, []); 25 | 26 | const addMessage = (message) => { 27 | setMessages(prev => [...prev, message]); 28 | }; 29 | 30 | const updateLastMessage = (content, isTyping = true) => { 31 | setMessages(prev => { 32 | const newMessages = [...prev]; 33 | if (newMessages.length > 0) { 34 | const lastMessage = newMessages[newMessages.length - 1]; 35 | newMessages[newMessages.length - 1] = { 36 | ...lastMessage, 37 | content, 38 | isTyping 39 | }; 40 | } 41 | return newMessages; 42 | }); 43 | }; 44 | 45 | const resetMessages = () => { 46 | setMessages([initialMessage]); 47 | }; 48 | 49 | return { 50 | messages, 51 | addMessage, 52 | updateLastMessage, 53 | resetMessages 54 | }; 55 | }; -------------------------------------------------------------------------------- /frontend/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer components { 6 | .markdown-content { 7 | @apply text-left w-full; 8 | } 9 | 10 | .markdown-content p { 11 | @apply mb-4 last:mb-0; 12 | } 13 | 14 | .markdown-content h1, 15 | .markdown-content h2, 16 | .markdown-content h3 { 17 | @apply font-bold mb-2 mt-6 first:mt-0; 18 | } 19 | 20 | .markdown-content h1 { 21 | @apply text-2xl; 22 | } 23 | 24 | .markdown-content h2 { 25 | @apply text-xl; 26 | } 27 | 28 | .markdown-content h3 { 29 | @apply text-lg; 30 | } 31 | 32 | .markdown-content pre { 33 | @apply bg-gray-700/50 rounded-lg p-3 my-4 overflow-x-auto; 34 | } 35 | 36 | .markdown-content code { 37 | @apply bg-gray-700/50 rounded px-1.5 py-0.5 font-mono text-sm; 38 | } 39 | 40 | .markdown-content pre code { 41 | @apply bg-transparent p-0; 42 | } 43 | 44 | .markdown-content ul { 45 | @apply list-disc pl-6 mb-4 space-y-2; 46 | } 47 | 48 | .markdown-content ol { 49 | @apply list-decimal pl-6 mb-4 space-y-2; 50 | } 51 | 52 | .markdown-content li { 53 | @apply pl-2; 54 | } 55 | 56 | .markdown-content li > p { 57 | @apply inline; 58 | } 59 | 60 | .markdown-content blockquote { 61 | @apply border-l-4 border-purple-500/50 pl-4 mb-4 italic; 62 | } 63 | } 64 | 65 | @layer utilities { 66 | .animate-pulse { 67 | animation: pulse 1s cubic-bezier(0.4, 0, 0.6, 1) infinite; 68 | } 69 | } 70 | 71 | @keyframes pulse { 72 | 0%, 100% { opacity: 1; } 73 | 50% { opacity: 0; } 74 | } -------------------------------------------------------------------------------- /frontend/src/components/layout/NavBar/index.jsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { motion } from 'framer-motion' 3 | import { Terminal, Menu, X } from 'lucide-react' 4 | import { Link } from 'react-router-dom' 5 | import { DesktopNav } from './DesktopNav' 6 | import { MobileNav } from './MobileNav' 7 | 8 | export const NavBar = () => { 9 | const [isMenuOpen, setIsMenuOpen] = useState(false) 10 | 11 | return ( 12 | 41 | ) 42 | } -------------------------------------------------------------------------------- /frontend/public/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "personal": { 3 | "name": "Alon Trugman", 4 | "email": "alon.xt@gmail.com", 5 | "profileImageAlt": "Alon Trugman" 6 | }, 7 | "social": { 8 | "github": { 9 | "url": "https://github.com/alonxt" 10 | }, 11 | "linkedin": { 12 | "url": "https://linkedin.com/in/alon-trugman" 13 | }, 14 | "email": { 15 | "url": "mailto:alon.xt@gmail.com" 16 | } 17 | }, 18 | "content": { 19 | "intro": { 20 | "cards": [ 21 | { 22 | "title": "Years of Growth", 23 | "description": "Documenting my life's journey", 24 | "icon": "Brain" 25 | }, 26 | { 27 | "title": "Knowledge Collection", 28 | "description": "My insights from books, podcasts, blogs and social media posts", 29 | "icon": "BookOpen" 30 | }, 31 | { 32 | "title": "AI Conversations", 33 | "description": "Based on my real experince and content", 34 | "icon": "MessageSquareText" 35 | } 36 | ], 37 | "paragraphs": [ 38 | "For years, I've been documenting my journey. This space is where I share the intersection of my professional expertise and personal growth.", 39 | "Using my authentic writings and reflections, you can explore my documented insights and experiences. While AI isn't perfect, I hope it will share my story as genuinely as possible." 40 | ] 41 | } 42 | }, 43 | "chat": { 44 | "inputPlaceholder": "Ask me anything about Alon...", 45 | "initialMessage": "Hey there! 👋 I'm Alon's AI assistant, I have access to his writings, and life insights. Feel free to ask and explore about his professional path or personal growth!" 46 | } 47 | } -------------------------------------------------------------------------------- /backend/app/controllers/chat_router.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends, HTTPException 2 | from fastapi.responses import StreamingResponse 3 | 4 | from app.factory import chat_service 5 | from app.logic.chat_service import ChatService 6 | from app.logs.logger import get_logger 7 | from app.models.data_structures import ChatRequest 8 | 9 | logger = get_logger(__name__) 10 | 11 | 12 | class ChatRouter: 13 | def __init__(self, 14 | chat_service: ChatService = Depends(chat_service) 15 | ): 16 | self.router = APIRouter() 17 | self.chat_service = chat_service 18 | self.add_routes() 19 | 20 | async def _home(self): 21 | """Home endpoint""" 22 | return {"Data": "Working!"} 23 | 24 | async def _health_check(self): 25 | """Health check endpoint""" 26 | return {"status": "healthy"} 27 | 28 | async def _chat(self, chat_request: ChatRequest) -> StreamingResponse: 29 | """Chat endpoint that streams responses""" 30 | try: 31 | response = StreamingResponse( 32 | self.chat_service.stream_chat(chat_request), 33 | media_type="text/event-stream" 34 | ) 35 | return response 36 | 37 | except Exception: 38 | logger.exception("Unexpected error during chat") 39 | raise HTTPException( 40 | status_code=500, 41 | detail="An internal server error occurred." 42 | ) 43 | 44 | def add_routes(self): 45 | self.router.add_api_route("/", self._home, methods=["GET"]) 46 | self.router.add_api_route("/health", self._health_check, methods=["GET"]) 47 | self.router.add_api_route("/chat", self._chat, methods=["POST"]) 48 | -------------------------------------------------------------------------------- /frontend/src/App.test.jsx: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi } from 'vitest'; 2 | import { screen } from '@testing-library/react'; 3 | import App from './App'; 4 | import React from 'react'; 5 | import './test/mocks'; 6 | import { renderWithConfig } from './test/testUtils'; 7 | 8 | // Mock the components 9 | vi.mock('./components/layout/MainLayout', () => ({ 10 | MainLayout: ({ children }) =>
{children}
11 | })); 12 | 13 | vi.mock('./components/sections/Home', () => ({ 14 | HomeSection: () =>
Home Section
15 | })); 16 | 17 | vi.mock('./components/sections/About', () => ({ 18 | AboutSection: () =>
About Section
19 | })); 20 | 21 | vi.mock('./components/sections/Projects', () => ({ 22 | ProjectsSection: () =>
Projects Section
23 | })); 24 | 25 | vi.mock('./components/sections/Blog', () => ({ 26 | BlogSection: () =>
Blog Section
27 | })); 28 | 29 | // Mock useNavigate for testing routes 30 | vi.mock('react-router-dom', async () => { 31 | const actual = await vi.importActual('react-router-dom'); 32 | return { 33 | ...actual, 34 | useNavigate: () => vi.fn() 35 | }; 36 | }); 37 | 38 | describe('App', () => { 39 | it('renders the main layout', () => { 40 | renderWithConfig(); 41 | expect(screen.getByTestId('main-layout')).toBeInTheDocument(); 42 | }); 43 | 44 | it('renders the home section by default', () => { 45 | renderWithConfig(); 46 | expect(screen.getByTestId('home-section')).toBeInTheDocument(); 47 | }); 48 | 49 | // Note: Testing route changes would require more complex setup with MemoryRouter 50 | // and is typically done in integration tests rather than unit tests 51 | }); -------------------------------------------------------------------------------- /backend/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | db: 5 | build: 6 | context: . 7 | dockerfile: Dockerfile.postgres 8 | restart: always 9 | healthcheck: 10 | test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] 11 | interval: 10s 12 | retries: 5 13 | start_period: 30s 14 | timeout: 10s 15 | volumes: 16 | - app-db-data:/var/lib/postgresql/data/pgdata 17 | env_file: 18 | - .env 19 | environment: 20 | - PGDATA=/var/lib/postgresql/data/pgdata 21 | networks: 22 | - backend-network 23 | # Uncomment ports if needed in development 24 | ports: 25 | - "5432:5432" 26 | 27 | redis: 28 | image: redis:7-alpine 29 | restart: always 30 | healthcheck: 31 | test: ["CMD", "redis-cli", "ping"] 32 | interval: 10s 33 | timeout: 5s 34 | retries: 5 35 | volumes: 36 | - redis-data:/data 37 | networks: 38 | - backend-network 39 | 40 | backend: 41 | build: 42 | context: . 43 | dockerfile: Dockerfile 44 | restart: always 45 | depends_on: 46 | db: 47 | condition: service_healthy 48 | redis: 49 | condition: service_healthy 50 | env_file: 51 | - .env 52 | environment: 53 | - POSTGRES_SERVER=db 54 | - REDIS_URL=redis://redis:6379 55 | - UVICORN_IP=0.0.0.0 56 | - FRONTEND_URL=http://localhost:5173 57 | healthcheck: 58 | test: ["CMD", "curl", "-f", "http://localhost:8000/health"] 59 | interval: 30s 60 | timeout: 10s 61 | retries: 3 62 | start_period: 20s 63 | networks: 64 | - backend-network 65 | ports: 66 | - "8000:8000" 67 | 68 | networks: 69 | backend-network: 70 | driver: bridge 71 | 72 | volumes: 73 | app-db-data: 74 | redis-data: -------------------------------------------------------------------------------- /frontend/src/components/layout/config/navigationConfig.js: -------------------------------------------------------------------------------- 1 | import { Github, Linkedin, Mail, BookOpen, Code, BookOpen as Blog } from 'lucide-react' 2 | import { getSocialLinks } from '../../../config/configLoader' 3 | 4 | // Social links are now loaded from the configuration 5 | // The icons are still defined here since they are React components 6 | export const getSocialLinksWithIcons = () => { 7 | const social = getSocialLinks(); 8 | 9 | // Create the social links array with icons 10 | return [ 11 | { 12 | href: social?.github?.url || "https://github.com", 13 | icon: Github, 14 | label: "GitHub", 15 | color: "hover:text-[#2DA44E]" // GitHub color 16 | }, 17 | { 18 | href: social?.linkedin?.url || "https://linkedin.com", 19 | icon: Linkedin, 20 | label: "LinkedIn", 21 | color: "hover:text-[#0A66C2]" // LinkedIn color 22 | }, 23 | { 24 | href: social?.email?.url || "mailto:example@example.com", 25 | icon: Mail, 26 | label: "Email", 27 | color: "hover:text-[#00C8DC]" // Matching site theme 28 | } 29 | ]; 30 | }; 31 | 32 | export const navConfig = { 33 | mobileMenuTransition: { 34 | initial: { opacity: 0, height: 0 }, 35 | animate: { opacity: 1, height: 'auto' }, 36 | exit: { opacity: 0, height: 0 } 37 | }, 38 | iconAnimation: { 39 | whileHover: { scale: 1.1 }, 40 | whileTap: { scale: 0.95 } 41 | } 42 | } 43 | 44 | export const sections = { 45 | about: { 46 | icon: BookOpen, 47 | title: 'About Me', 48 | path: '/about-me', 49 | color: 'from-blue-500 to-cyan-500' 50 | }, 51 | projects: { 52 | icon: Code, 53 | title: 'Projects', 54 | path: '/projects', 55 | color: 'from-emerald-500 to-green-500' 56 | }, 57 | blog: { 58 | icon: Blog, 59 | title: 'Blog', 60 | path: '/blog', 61 | color: 'from-pink-500 to-rose-500' 62 | } 63 | } -------------------------------------------------------------------------------- /frontend/src/components/sections/Home/TypingIndicator.jsx: -------------------------------------------------------------------------------- 1 | import { Bot } from 'lucide-react'; 2 | import { motion } from 'framer-motion'; 3 | 4 | 5 | export const TypingIndicator = ({ inline = false }) => { 6 | // If inline, render just the dots 7 | if (inline) { 8 | return ( 9 | 10 | {[0, 0.15, 0.3].map((delay, i) => ( 11 | 16 | ))} 17 | 18 | ); 19 | } 20 | 21 | // Otherwise render the full message bubble with avatar 22 | return ( 23 | 31 |
32 | 33 |
34 |
35 |
36 |
37 | 38 | {[0, 0.15, 0.3].map((delay, i) => ( 39 | 44 | ))} 45 | 46 |
47 |
48 |
49 |
50 | ); 51 | }; -------------------------------------------------------------------------------- /frontend/src/components/layout/MainLayout.test.jsx: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi } from 'vitest'; 2 | import { screen } from '@testing-library/react'; 3 | import { MainLayout } from './MainLayout'; 4 | import React from 'react'; 5 | import '../../test/mocks'; 6 | import { renderWithConfig } from '../../test/testUtils'; 7 | 8 | // Mock the NavBar component 9 | vi.mock('./NavBar', () => ({ 10 | NavBar: () =>
Navbar Mock
11 | })); 12 | 13 | describe('MainLayout', () => { 14 | it('renders the NavBar component', () => { 15 | renderWithConfig( 16 | 17 |
Test Content
18 |
19 | ); 20 | 21 | expect(screen.getByTestId('navbar-mock')).toBeInTheDocument(); 22 | }); 23 | 24 | it('renders the children content', () => { 25 | renderWithConfig( 26 | 27 |
Test Content
28 |
29 | ); 30 | 31 | expect(screen.getByTestId('test-content')).toBeInTheDocument(); 32 | expect(screen.getByText('Test Content')).toBeInTheDocument(); 33 | }); 34 | 35 | it('applies the correct styling classes', () => { 36 | renderWithConfig( 37 | 38 |
Test Content
39 |
40 | ); 41 | 42 | // Check for the main container with the correct classes 43 | const mainContainer = screen.getByText('Test Content').closest('.min-h-screen'); 44 | expect(mainContainer).toHaveClass('min-h-screen'); 45 | expect(mainContainer).toHaveClass('bg-black'); 46 | expect(mainContainer).toHaveClass('text-white'); 47 | 48 | // Check for the main content area 49 | const mainContent = screen.getByText('Test Content').closest('main'); 50 | expect(mainContent).toHaveClass('pt-24'); 51 | expect(mainContent).toHaveClass('px-6'); 52 | expect(mainContent).toHaveClass('max-w-7xl'); 53 | expect(mainContent).toHaveClass('mx-auto'); 54 | }); 55 | }); -------------------------------------------------------------------------------- /frontend/src/components/layout/NavBar/MobileNav.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types' 2 | import { motion, AnimatePresence } from 'framer-motion' 3 | import { NavLink } from 'react-router-dom' 4 | import { sections } from '../config/navigationConfig' 5 | import { navConfig } from '../config/navigationConfig' 6 | import { SocialLinks } from '../shared/SocialLinks' 7 | 8 | export const MobileNav = ({ isMenuOpen, setIsMenuOpen }) => { 9 | return ( 10 | 11 | {isMenuOpen && ( 12 | 16 | {/* Navigation Links */} 17 |
18 | {Object.entries(sections).map(([key, { icon: Icon, title, path }]) => ( 19 | setIsMenuOpen(false)} 23 | className={({ isActive }) => ` 24 | px-4 py-3 rounded-lg flex items-center space-x-2 transition-colors w-full 25 | ${isActive 26 | ? 'bg-white/10 text-white' 27 | : 'text-gray-400 hover:text-[#00C8DC] hover:bg-white/5'} 28 | `} 29 | > 30 | 31 | {title} 32 | 33 | ))} 34 |
35 | 36 | {/* Social Links */} 37 |
38 |
Connect with me
39 |
40 | 41 |
42 |
43 |
44 | )} 45 |
46 | ) 47 | } 48 | 49 | MobileNav.propTypes = { 50 | isMenuOpen: PropTypes.bool.isRequired, 51 | setIsMenuOpen: PropTypes.func.isRequired 52 | } -------------------------------------------------------------------------------- /frontend/src/components/sections/Home/index.test.jsx: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi } from 'vitest'; 2 | import { screen } from '@testing-library/react'; 3 | import { HomeSection } from './index'; 4 | import React from 'react'; 5 | import '../../../test/mocks'; 6 | import { renderWithConfig } from '../../../test/testUtils'; 7 | 8 | // Mock the child components 9 | vi.mock('./ChatBox', () => ({ 10 | ChatBox: () => React.createElement('div', { 'data-testid': 'chat-box' }) 11 | })); 12 | 13 | vi.mock('./IntroSection', () => ({ 14 | IntroSection: () => React.createElement('div', { 'data-testid': 'intro-section' }) 15 | })); 16 | 17 | // Mock the Lucide React icon 18 | vi.mock('lucide-react', () => ({ 19 | Terminal: () => React.createElement('div', { 'data-testid': 'terminal-icon' }) 20 | })); 21 | 22 | describe('HomeSection', () => { 23 | it('renders the profile image and name', () => { 24 | renderWithConfig(); 25 | 26 | // Check that the profile image is rendered 27 | const profileImage = screen.getByAltText('Test User'); 28 | expect(profileImage).toBeInTheDocument(); 29 | expect(profileImage.tagName).toBe('IMG'); 30 | expect(profileImage).toHaveAttribute('src', '/profile.jpg'); 31 | 32 | // Check that the name is rendered 33 | expect(screen.getByText('Test User')).toBeInTheDocument(); 34 | }); 35 | 36 | it('renders the Terminal icon', () => { 37 | renderWithConfig(); 38 | 39 | // Check that the Terminal icon is rendered 40 | expect(screen.getByTestId('terminal-icon')).toBeInTheDocument(); 41 | }); 42 | 43 | it('renders the IntroSection component', () => { 44 | renderWithConfig(); 45 | 46 | // Check that the IntroSection component is rendered 47 | expect(screen.getByTestId('intro-section')).toBeInTheDocument(); 48 | }); 49 | 50 | it('renders the ChatBox component', () => { 51 | renderWithConfig(); 52 | 53 | // Check that the ChatBox component is rendered 54 | expect(screen.getByTestId('chat-box')).toBeInTheDocument(); 55 | }); 56 | }); -------------------------------------------------------------------------------- /frontend/src/components/sections/Home/IntroSection.test.jsx: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi } from 'vitest'; 2 | import { screen } from '@testing-library/react'; 3 | import { IntroSection } from './IntroSection'; 4 | import React from 'react'; 5 | import '../../../test/mocks'; 6 | import { renderWithConfig } from '../../../test/testUtils'; 7 | 8 | // Mock the Lucide React icons 9 | vi.mock('lucide-react', () => ({ 10 | Brain: () => React.createElement('div', { 'data-testid': 'brain-icon' }), 11 | BookOpen: () => React.createElement('div', { 'data-testid': 'book-open-icon' }), 12 | MessageSquareText: () => React.createElement('div', { 'data-testid': 'message-square-text-icon' }) 13 | })); 14 | 15 | describe('IntroSection', () => { 16 | it('renders the section with all feature cards', () => { 17 | renderWithConfig(); 18 | 19 | // Check that all three cards are rendered 20 | expect(screen.getByText('Years of Growth')).toBeInTheDocument(); 21 | expect(screen.getByText('Knowledge Collection')).toBeInTheDocument(); 22 | expect(screen.getByText('AI Conversations')).toBeInTheDocument(); 23 | 24 | // Check that all descriptions are rendered 25 | expect(screen.getByText('Documenting my journey from 2.5 years of therapy')).toBeInTheDocument(); 26 | expect(screen.getByText('My insights from books, podcasts, blogs and social media posts')).toBeInTheDocument(); 27 | expect(screen.getByText('Based on my real experince and content')).toBeInTheDocument(); 28 | 29 | // Check that all icons are rendered 30 | expect(screen.getByTestId('brain-icon')).toBeInTheDocument(); 31 | expect(screen.getByTestId('book-open-icon')).toBeInTheDocument(); 32 | expect(screen.getByTestId('message-square-text-icon')).toBeInTheDocument(); 33 | }); 34 | 35 | it('renders the content section with paragraphs', () => { 36 | renderWithConfig(); 37 | 38 | // Check that the content paragraphs are rendered 39 | expect(screen.getByText("Test paragraph")).toBeInTheDocument(); 40 | expect(screen.getByText("Test paragraph 2")).toBeInTheDocument(); 41 | }); 42 | }); -------------------------------------------------------------------------------- /frontend/src/components/sections/Projects/index.jsx: -------------------------------------------------------------------------------- 1 | import { motion } from 'framer-motion' 2 | import { Github } from 'lucide-react' 3 | import { useGithubRepos } from '../../../hooks/useGithubRepos' 4 | 5 | export const ProjectsSection = () => { 6 | const { repos, loading, error } = useGithubRepos('alonxt', 6) 7 | 8 | if (loading) return
Loading...
9 | if (error) return
Error loading projects
10 | 11 | return ( 12 | 19 | {repos.map((repo, index) => ( 20 | 27 |
28 |
29 |

{repo.name}

30 |

31 | {repo.description || 'No description available'} 32 |

33 | 44 |
45 | 46 | ))} 47 | 48 | ) 49 | } -------------------------------------------------------------------------------- /frontend/src/components/sections/Home/index.jsx: -------------------------------------------------------------------------------- 1 | import { motion } from 'framer-motion' 2 | import { Terminal } from 'lucide-react' 3 | import { ChatBox } from './ChatBox' 4 | import { IntroSection } from './IntroSection' 5 | import { getPersonalInfo } from '../../../config/configLoader' 6 | 7 | export const HomeSection = () => { 8 | const personalInfo = getPersonalInfo(); 9 | 10 | return ( 11 | 18 | 24 |
25 | {personalInfo.name} 30 |
31 |
32 | 37 | 38 | 39 | 40 | 41 | 47 | {personalInfo.name} 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | ) 56 | } -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # React Frontend Service 🎨 2 | 3 | [![React](https://img.shields.io/badge/React-20232A?style=for-the-badge&logo=react&logoColor=61DAFB)](https://reactjs.org/) 4 | [![Vite](https://img.shields.io/badge/Vite-B73BFE?style=for-the-badge&logo=vite&logoColor=FFD62E)](https://vitejs.dev/) 5 | [![TailwindCSS](https://img.shields.io/badge/Tailwind_CSS-38B2AC?style=for-the-badge&logo=tailwind-css&logoColor=white)](https://tailwindcss.com/) 6 | 7 | 8 | ## 🚀 Local Setup Instructions 9 | 10 | 1. Ensure Node.js 20.x is installed 11 | 12 | 2. Install dependencies: 13 | ```bash 14 | npm install 15 | ``` 16 | 17 | 3. Configure environment: 18 | ```bash 19 | # Create .env file with: 20 | VITE_BACKEND_URL=http://localhost:8000 # or your backend URL 21 | ``` 22 | 23 | 4. Add your personal content: 24 | - Add `about-me.md` to public folder 25 | - Add `icon.svg` to public folder 26 | - Add `profile.jpg` to public folder 27 | - Update `config.json` following the [Config Setup Guide](CONFIGURATION.md) 28 | 29 | 5. Start development server: 30 | ```bash 31 | npm run dev 32 | ``` 33 | 34 | 6. Visit http://localhost:5173 to view the app 🎉 35 | 36 | ## 🛠 Development Scripts 37 | 38 | ```bash 39 | # Start development server 40 | npm run dev 41 | 42 | # Build for production 43 | npm run build 44 | 45 | # Preview production build 46 | npm run preview 47 | ``` 48 | 49 | ## 🧪 Testing 50 | 51 | This project uses Vitest and React Testing Library for unit testing. Tests are organized alongside their components. 52 | 53 | ### Running Tests 54 | 55 | ```bash 56 | # Run all tests 57 | npm test 58 | 59 | # Run tests in watch mode 60 | npm run test:watch 61 | 62 | # Run tests with coverage 63 | npm test -- --coverage 64 | ``` 65 | 66 | ## 🚀 Deployment on Vercel 67 | 68 | 1. Create an account on [Vercel.com](https://vercel.com) 69 | 70 | 2. Create a new project and connect to your repository 71 | 72 | 3. Configure environment variables: 73 | ```bash 74 | VITE_BACKEND_URL=https://your-backend-url 75 | ``` 76 | 77 | 4. Deploy! Your app will be available at https://your-project-name.vercel.app 🎉 78 | 79 | ## 🛠 Tech Stack 80 | 81 | - React 18 82 | - Vite 83 | - TailwindCSS 84 | - Framer Motion 85 | - React Router DOM 86 | - ESLint 87 | 88 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | db: 3 | build: 4 | context: ./backend 5 | dockerfile: Dockerfile.postgres 6 | restart: always 7 | healthcheck: 8 | test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] 9 | interval: 10s 10 | retries: 5 11 | start_period: 30s 12 | timeout: 10s 13 | volumes: 14 | - app-db-data:/var/lib/postgresql/data/pgdata 15 | env_file: 16 | - .env 17 | environment: 18 | - PGDATA=/var/lib/postgresql/data/pgdata 19 | networks: 20 | - backend-network 21 | # Only expose ports in development 22 | # ports: 23 | # - "${POSTGRES_PORT}:5432" 24 | 25 | redis: 26 | image: redis:7-alpine 27 | restart: always 28 | healthcheck: 29 | test: ["CMD", "redis-cli", "ping"] 30 | interval: 10s 31 | timeout: 5s 32 | retries: 5 33 | networks: 34 | - backend-network 35 | volumes: 36 | - redis-data:/data 37 | 38 | backend: 39 | build: 40 | context: ./backend 41 | dockerfile: Dockerfile 42 | restart: always 43 | depends_on: 44 | db: 45 | condition: service_healthy 46 | redis: 47 | condition: service_healthy 48 | env_file: 49 | - .env 50 | environment: 51 | - POSTGRES_SERVER=db 52 | - REDIS_URL=redis://redis:6379 53 | - UVICORN_IP=0.0.0.0 54 | - FRONTEND_URL=http://localhost:3000 55 | healthcheck: 56 | test: ["CMD", "curl", "-f", "http://localhost:8000/health"] 57 | interval: 30s 58 | timeout: 10s 59 | retries: 3 60 | start_period: 20s 61 | networks: 62 | - backend-network 63 | - frontend-network 64 | ports: 65 | - "8000:8000" 66 | 67 | 68 | frontend: 69 | build: 70 | context: ./frontend 71 | dockerfile: Dockerfile 72 | restart: always 73 | depends_on: 74 | backend: 75 | condition: service_healthy 76 | networks: 77 | - frontend-network 78 | ports: 79 | - "3000:80" 80 | 81 | 82 | networks: 83 | backend-network: 84 | internal: true # This network is not accessible from outside Docker 85 | frontend-network: 86 | # External access is allowed for the frontend network 87 | 88 | volumes: 89 | app-db-data: 90 | redis-data: -------------------------------------------------------------------------------- /backend/app/startup/documents/init_documents.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from app.logs.logger import get_logger 3 | from app.db.db_handler import DatabaseHandler 4 | from app.logic.document_indexer import DocumentIndexer 5 | 6 | logger = get_logger(__name__) 7 | 8 | 9 | async def init_documents(document_indexer: DocumentIndexer, db_handler: DatabaseHandler): 10 | """ 11 | Process markdown documents found in the docs folder by reading each file, 12 | chunking it with DocumentService and storing it into the database. 13 | Updates existing documents if their content has changed. 14 | """ 15 | 16 | docs_dir = Path(__file__).resolve().parents[3] / "docs" 17 | 18 | logger.info(f"Looking for documents in: {docs_dir}") 19 | 20 | if not docs_dir.exists(): 21 | logger.error(f"Docs directory {docs_dir} does not exist.") 22 | return 23 | 24 | for file_path in docs_dir.glob("*.md"): 25 | str_path = str(file_path) 26 | logger.info(f"Checking file: {str_path}") 27 | 28 | # Read the current file content and calculate its hash 29 | with open(str_path, 'r') as file: 30 | content = file.read() 31 | current_hash = document_indexer.calculate_content_hash(content) 32 | 33 | # Check if document exists and get its stored hash 34 | if await db_handler.document_exists(str_path): 35 | stored_hash = await db_handler.get_document_hash(str_path) 36 | 37 | if stored_hash == current_hash: 38 | logger.debug(f"Skipping unchanged file: {str_path}") 39 | continue 40 | 41 | logger.info(f"Updating modified file: {str_path}") 42 | # Delete existing chunks before processing updated content 43 | await db_handler.delete_document_chunks(str_path) 44 | else: 45 | logger.info(f"Processing new file: {str_path}") 46 | 47 | # Process and store the document chunks 48 | chunks = document_indexer.process_markdown(str_path) 49 | for chunk in chunks: 50 | await db_handler.store_document_chunk( 51 | content=chunk['content'], 52 | embedding=chunk['embedding'], 53 | metadata=chunk['metadata'] 54 | ) -------------------------------------------------------------------------------- /frontend/src/config/configLoader.js: -------------------------------------------------------------------------------- 1 | // Cache for the loaded configuration 2 | let configCache = null; 3 | // Flag to track if we've started loading the config 4 | let isLoading = false; 5 | // Promise that resolves when the config is loaded 6 | let configLoadingPromise = null; 7 | 8 | /** 9 | * Load the configuration from the config.json file 10 | * @returns {Promise} The configuration object 11 | * @throws {Error} If the config.json file is not found or cannot be loaded 12 | */ 13 | export const loadConfig = async () => { 14 | if (configCache) { 15 | return configCache; 16 | } 17 | 18 | if (isLoading && configLoadingPromise) { 19 | return configLoadingPromise; 20 | } 21 | 22 | isLoading = true; 23 | configLoadingPromise = new Promise(async (resolve, reject) => { 24 | try { 25 | const response = await fetch('/config.json'); 26 | const config = await response.json(); 27 | configCache = config; 28 | resolve(config); 29 | } catch (error) { 30 | reject(new Error('Failed to load configuration. The application requires a valid config.json file in the public directory.')); 31 | } finally { 32 | isLoading = false; 33 | } 34 | }); 35 | 36 | return configLoadingPromise; 37 | }; 38 | 39 | // Start loading the config immediately when this module is imported 40 | loadConfig() 41 | 42 | /** 43 | * Get a configuration value 44 | * @param {string} section - The section of the configuration (e.g., 'personal', 'social') 45 | * @param {string} key - The specific key within the section 46 | * @param {*} defaultValue - Default value if the key is not found 47 | * @returns {*} The configuration value or default if not found 48 | * @throws {Error} If the configuration is not loaded 49 | */ 50 | export const getConfig = (section, key, defaultValue) => { 51 | // If config is not loaded yet, throw an error 52 | if (!configCache) { 53 | throw new Error('Configuration not loaded. The application requires a valid config.json file in the public directory.'); 54 | } 55 | 56 | if (!section) return configCache; 57 | if (!key) return configCache[section] || defaultValue; 58 | return configCache[section]?.[key] || defaultValue; 59 | }; 60 | 61 | export const getPersonalInfo = () => getConfig('personal'); 62 | 63 | export const getSocialLinks = () => getConfig('social'); 64 | 65 | export const getChatConfig = () => getConfig('chat'); 66 | 67 | export const getContentConfig = () => getConfig('content'); -------------------------------------------------------------------------------- /frontend/src/components/layout/config/navigationConfig.test.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { getSocialLinksWithIcons, navConfig } from './navigationConfig'; 3 | import { Github, Linkedin, Mail } from 'lucide-react'; 4 | import '../../../test/mocks'; 5 | 6 | describe('navigationConfig', () => { 7 | describe('socialLinks', () => { 8 | const socialLinks = getSocialLinksWithIcons(); 9 | 10 | it('contains the correct number of social links', () => { 11 | expect(socialLinks).toHaveLength(3); 12 | }); 13 | 14 | it('has GitHub link with correct properties', () => { 15 | const githubLink = socialLinks.find(link => link.label === 'GitHub'); 16 | expect(githubLink).toBeDefined(); 17 | expect(githubLink.href).toContain('https://github.com/'); 18 | expect(githubLink.icon).toBe(Github); 19 | expect(githubLink.color).toBe('hover:text-[#2DA44E]'); 20 | }); 21 | 22 | it('has LinkedIn link with correct properties', () => { 23 | const linkedinLink = socialLinks.find(link => link.label === 'LinkedIn'); 24 | expect(linkedinLink).toBeDefined(); 25 | expect(linkedinLink.href).toContain('https://linkedin.com/in/'); 26 | expect(linkedinLink.icon).toBe(Linkedin); 27 | expect(linkedinLink.color).toBe('hover:text-[#0A66C2]'); 28 | }); 29 | 30 | it('has Email link with correct properties', () => { 31 | const emailLink = socialLinks.find(link => link.label === 'Email'); 32 | expect(emailLink).toBeDefined(); 33 | expect(emailLink.href).toContain('mailto:'); 34 | expect(emailLink.icon).toBe(Mail); 35 | expect(emailLink.color).toBe('hover:text-[#00C8DC]'); 36 | }); 37 | }); 38 | 39 | describe('navConfig', () => { 40 | it('has mobileMenuTransition with correct properties', () => { 41 | expect(navConfig.mobileMenuTransition).toBeDefined(); 42 | expect(navConfig.mobileMenuTransition.initial).toEqual({ opacity: 0, height: 0 }); 43 | expect(navConfig.mobileMenuTransition.animate).toEqual({ opacity: 1, height: 'auto' }); 44 | expect(navConfig.mobileMenuTransition.exit).toEqual({ opacity: 0, height: 0 }); 45 | }); 46 | 47 | it('has iconAnimation with correct properties', () => { 48 | expect(navConfig.iconAnimation).toBeDefined(); 49 | expect(navConfig.iconAnimation.whileHover).toEqual({ scale: 1.1 }); 50 | expect(navConfig.iconAnimation.whileTap).toEqual({ scale: 0.95 }); 51 | }); 52 | }); 53 | }); -------------------------------------------------------------------------------- /backend/tests/db/test_db_config.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from urllib.parse import quote_plus 3 | 4 | from app.db.db_config import PostgresConfig 5 | 6 | # Tests 7 | @pytest.mark.unit 8 | def test_postgres_config_default_values(): 9 | """Test that PostgresConfig sets default values correctly""" 10 | # given 11 | config = PostgresConfig( 12 | db_name="testdb", 13 | user="testuser", 14 | password="testpass" 15 | ) 16 | 17 | # then 18 | assert config.server == "localhost" 19 | assert config.port == 5432 20 | assert config.db_name == "testdb" 21 | assert config.user == "testuser" 22 | assert config.password == "testpass" 23 | 24 | @pytest.mark.unit 25 | def test_postgres_config_custom_values(): 26 | """Test that PostgresConfig accepts custom values""" 27 | # given 28 | config = PostgresConfig( 29 | server="custom-server", 30 | port=5433, 31 | db_name="customdb", 32 | user="customuser", 33 | password="custompass" 34 | ) 35 | 36 | # then 37 | assert config.server == "custom-server" 38 | assert config.port == 5433 39 | assert config.db_name == "customdb" 40 | assert config.user == "customuser" 41 | assert config.password == "custompass" 42 | 43 | @pytest.mark.unit 44 | def test_get_connection_url_basic(): 45 | """Test that get_connection_url formats the connection string correctly""" 46 | # given 47 | config = PostgresConfig( 48 | server="localhost", 49 | port=5432, 50 | db_name="testdb", 51 | user="testuser", 52 | password="testpass" 53 | ) 54 | 55 | # when 56 | url = config.get_connection_url() 57 | 58 | # then 59 | assert url == "postgresql://testuser:testpass@localhost:5432/testdb" 60 | 61 | @pytest.mark.unit 62 | def test_get_connection_url_with_special_chars(): 63 | """Test that get_connection_url properly encodes special characters in password""" 64 | # given 65 | special_password = "test@pass&with:special/chars" 66 | config = PostgresConfig( 67 | db_name="testdb", 68 | user="testuser", 69 | password=special_password 70 | ) 71 | 72 | # when 73 | url = config.get_connection_url() 74 | expected_encoded_password = quote_plus(special_password) 75 | 76 | # then 77 | assert url == f"postgresql://testuser:{expected_encoded_password}@localhost:5432/testdb" 78 | # Verify the password is properly encoded 79 | assert expected_encoded_password in url 80 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Interactive AI Portfolio 🤖 2 | 3 | [![FastAPI](https://img.shields.io/badge/FastAPI-005571?style=for-the-badge&logo=fastapi)](https://fastapi.tiangolo.com/) 4 | [![React](https://img.shields.io/badge/React-20232A?style=for-the-badge&logo=react&logoColor=61DAFB)](https://reactjs.org/) 5 | [![Vite](https://img.shields.io/badge/Vite-B73BFE?style=for-the-badge&logo=vite&logoColor=FFD62E)](https://vitejs.dev/) 6 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=for-the-badge)](https://opensource.org/licenses/MIT) 7 | 8 | Create your own engaging, AI-powered conversation portfolio. This open-source project enables developers to create interactive portfolios where visitors can have meaningful conversations with an AI assistant that knows about your work, experience, and expertise. 9 | 10 | ## ✨ Key Features 11 | 12 | - 🤖 **Interactive AI Assistant**: Engage visitors with personalized, context-aware conversations 13 | - 🚀 **Real-time Streaming**: Fluid, chat-like experience with streaming responses 14 | - 🎨 **Modern UI**: Clean, responsive design focused on conversation 15 | - 🔄 **Easy to Customize**: Create your own from template and modify for your personal brand 16 | - 🛠 **Modular Architecture**: Built for maintanability and easy extension 17 | 18 | ## 🏗 Architecture 19 | 20 | ```mermaid 21 | graph LR 22 | A[React Frontend] --> B[FastAPI Backend] 23 | B --> C[LLM Service] 24 | B --> D[Vector Store] 25 | B --> E[Redis Cache] 26 | ``` 27 | 28 | ### Tech Stack 29 | 30 | - **Frontend**: React + Vite, TailwindCSS, Framer Motion 31 | - **Backend**: FastAPI, PostgreSQL + pgvector, Redis 32 | 33 | 34 | ## 🚀 Quick Start 35 | 36 | 1. Create a new repository from this template and clone it 37 | 2. Add the necessary files to frontend/public and change the config.json file to your own content - [Config Setup Guide](frontend/CONFIGURATION.md) 38 | 2. Run "docker compose build" and then "docker compose up" to start the containers and enjoy it on [localhost](http//:localhost:3000) 39 | 3. Deploy to [fly.io](fly.io) or set up locally- [Backend Setup Guide](backend/README.md) 40 | 4. Deploy to [vercel.com](vercel.com) or set up locally- [Frontend Setup Guide](frontend/README.md) 41 | 42 | 43 | ## 🤝 Contributing 44 | 45 | Contributions are welcome! Please feel free to submit a Pull Request. 46 | 47 | ## 📝 License 48 | 49 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 50 | 51 | 52 |
53 | Made with ❤️ by Alon Trugman 54 |
55 | 56 | -------------------------------------------------------------------------------- /frontend/src/services/chatService.js: -------------------------------------------------------------------------------- 1 | const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:8000'; 2 | 3 | export class ChatService { 4 | static async handleStreamingResponse(response, callbacks) { 5 | const reader = response.body.getReader(); 6 | const decoder = new TextDecoder(); 7 | let fullMessage = ''; 8 | 9 | try { 10 | while (true) { 11 | if (callbacks.shouldStop?.()) { 12 | await reader.cancel(); 13 | callbacks.onComplete(fullMessage); 14 | break; 15 | } 16 | 17 | const { done, value } = await reader.read(); 18 | 19 | if (done) { 20 | callbacks.onComplete(fullMessage); 21 | break; 22 | } 23 | 24 | const chunk = decoder.decode(value); 25 | const lines = chunk.split('\n'); 26 | 27 | for (const line of lines) { 28 | if (line.trim() === '') continue; 29 | 30 | const match = line.match(/^0:(.+)$/); 31 | if (match) { 32 | try { 33 | const content = JSON.parse(match[1]); 34 | fullMessage += content; 35 | callbacks.onChunk(fullMessage); 36 | await new Promise(resolve => setTimeout(resolve, 30)); 37 | } catch (e) { 38 | console.error('Error parsing streaming response:', e); 39 | } 40 | } 41 | } 42 | } 43 | } catch (e) { 44 | console.error('Error reading stream:', e); 45 | throw new Error('Error processing response stream'); 46 | } 47 | } 48 | 49 | static async handleError(response) { 50 | const errorData = await response.json(); 51 | if (response.status === 429) { 52 | return { 53 | isRateLimit: true, 54 | message: errorData.friendly_message || "You've reached the rate limit. Please wait before sending more messages.", 55 | retryAfter: errorData.retry_after 56 | }; 57 | } 58 | throw new Error(errorData.detail || 'Failed to send message'); 59 | } 60 | 61 | static async sendMessage(chatRequest) { 62 | const response = await fetch(`${BACKEND_URL}/chat`, { 63 | method: 'POST', 64 | headers: { 65 | 'Content-Type': 'application/json', 66 | }, 67 | body: JSON.stringify({ 68 | ...chatRequest, 69 | messages: chatRequest.messages.map(({ role, content }) => ({ role, content })) 70 | }), 71 | }); 72 | 73 | if (!response.ok) { 74 | return this.handleError(response); 75 | } 76 | 77 | return response; 78 | } 79 | } -------------------------------------------------------------------------------- /frontend/src/config/ConfigProvider.jsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext, useEffect, useState } from 'react'; 2 | import { loadConfig } from './configLoader'; 3 | 4 | // Create a context for the configuration 5 | const ConfigContext = createContext(null); 6 | 7 | /** 8 | * Configuration provider component 9 | * Loads the configuration and provides it to all child components 10 | */ 11 | export const ConfigProvider = ({ children }) => { 12 | const [config, setConfig] = useState(null); 13 | const [loading, setLoading] = useState(true); 14 | const [error, setError] = useState(null); 15 | 16 | useEffect(() => { 17 | const fetchConfig = async () => { 18 | try { 19 | const configData = await loadConfig(); 20 | setConfig(configData); 21 | setLoading(false); 22 | } catch (err) { 23 | setError(err.message || 'Failed to load configuration'); 24 | setLoading(false); 25 | } 26 | }; 27 | 28 | fetchConfig(); 29 | }, []); 30 | 31 | // If there's an error loading the configuration, display an error message 32 | if (error) { 33 | return ( 34 |
35 |
36 |

Configuration Error

37 |

{error}

38 |

39 | Please ensure that a valid config.json file exists in the public directory of the frontend application. 40 |

41 |
42 |
43 | ); 44 | } 45 | 46 | // If still loading, you could show a loading indicator 47 | if (loading) { 48 | return ( 49 |
50 |
51 |
52 | ); 53 | } 54 | 55 | // Provide the configuration to all child components 56 | return ( 57 | 58 | {children} 59 | 60 | ); 61 | }; 62 | 63 | /** 64 | * Hook to access the configuration 65 | * @returns {Object} The configuration context 66 | */ 67 | export const useConfig = () => { 68 | const context = useContext(ConfigContext); 69 | if (context === null) { 70 | throw new Error('useConfig must be used within a ConfigProvider'); 71 | } 72 | return context; 73 | }; -------------------------------------------------------------------------------- /backend/app/models/data_structures.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, field_validator 2 | from datetime import datetime, timezone 3 | from typing import Optional, List 4 | from sqlmodel import SQLModel, Field 5 | from sqlalchemy import Column 6 | from sqlalchemy.dialects.postgresql import JSONB 7 | from pgvector.sqlalchemy import Vector 8 | from datetime import datetime 9 | 10 | 11 | class Message(BaseModel): 12 | role: str 13 | content: str 14 | 15 | 16 | class ChatRequest(BaseModel): 17 | message: str 18 | messages: List[Message] 19 | session_id: str 20 | timestamp: float 21 | 22 | @field_validator('timestamp') 23 | def convert_timestamp(cls, v: float) -> datetime: 24 | # Convert Unix timestamp to datetime 25 | return datetime.fromtimestamp(v, tz=timezone.utc) 26 | 27 | 28 | class ChatLog(SQLModel, table=True): 29 | id: Optional[int] = Field(default=None, primary_key=True) 30 | session_id: str = Field(index=True) 31 | user_message: str = Field(index=True) 32 | assistant_message: str = Field(default="") 33 | timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) 34 | created_at: datetime = Field( 35 | default_factory=lambda: datetime.now(timezone.utc), 36 | nullable=False 37 | ) 38 | 39 | class Config: 40 | json_encoders = { 41 | datetime: lambda dt: dt.isoformat() 42 | } 43 | 44 | 45 | class RateLimitResponse(BaseModel): 46 | """Response structure for rate limit exceeded cases""" 47 | detail: str 48 | type: str 49 | limit: str 50 | retry_after: int # seconds until the limit resets 51 | friendly_message: str # user-friendly message about when they can retry 52 | 53 | class Config: 54 | json_schema_extra = { 55 | "example": { 56 | "detail": "Rate limit exceeded", 57 | "type": "chat_rate_limit_exceeded", 58 | "limit": "60/minute", 59 | "retry_after": 30, 60 | "friendly_message": "You're sending messages too quickly! You can send another message in 30 seconds." 61 | } 62 | } 63 | 64 | class DocumentChunk(SQLModel, table=True): 65 | id: Optional[int] = Field(default=None, primary_key=True) 66 | content: str = Field(nullable=False) 67 | embedding: List[float] = Field(sa_column=Column(Vector(1536))) 68 | doc_metadata: dict = Field( 69 | default_factory=dict, 70 | sa_column=Column("doc_metadata", JSONB) 71 | ) 72 | created_at: datetime = Field( 73 | default_factory=lambda: datetime.now(timezone.utc), 74 | nullable=False 75 | ) -------------------------------------------------------------------------------- /frontend/CONFIGURATION.md: -------------------------------------------------------------------------------- 1 | # Configuration System 2 | 3 | This document explains the configuration system used in the AI Portfolio project. The configuration system allows you to customize the content of your portfolio without modifying the code. 4 | 5 | ## Overview 6 | 7 | The project uses a single `config.json` file in the `public` directory to store all configuration values. This file is essential for the application to function properly. The frontend requires this file to be present and will display an error message if it cannot be found. 8 | 9 | ## Configuration File Structure 10 | 11 | The `config.json` file has the following structure: 12 | 13 | ```json 14 | { 15 | "personal": { 16 | "name": "Your Name", 17 | "email": "your.email@example.com", 18 | }, 19 | "social": { 20 | "github": { 21 | "url": "https://github.com/yourusername" 22 | }, 23 | "linkedin": { 24 | "url": "https://linkedin.com/in/yourusername" 25 | }, 26 | "email": { 27 | "url": "mailto:your.email@example.com" 28 | } 29 | }, 30 | "content": { 31 | "intro": { 32 | "cards": [ 33 | { 34 | "title": "Feature 1", 35 | "description": "Description of feature 1", 36 | "icon": "Brain" 37 | }, 38 | { 39 | "title": "Feature 2", 40 | "description": "Description of feature 2", 41 | "icon": "BookOpen" 42 | }, 43 | { 44 | "title": "Feature 3", 45 | "description": "Description of feature 3", 46 | "icon": "MessageSquareText" 47 | } 48 | ], 49 | "paragraphs": [ 50 | "Main paragraph about yourself.", 51 | "Secondary paragraph with additional information." 52 | ] 53 | } 54 | }, 55 | "chat": { 56 | "inputPlaceholder": "Ask me anything about Your Name...", 57 | "initialMessage": "Hey there! 👋 I'm Your Name's AI assistant, I have access to their writings, and life insights. Feel free to ask and explore about their professional path or personal growth!" 58 | } 59 | } 60 | ``` 61 | 62 | ## How to Customize 63 | 64 | 1. Edit the `config.json` file in the `public` directory 65 | 2. Update the values to match your information 66 | 3. Restart the development server or rebuild the application 67 | 68 | ## Technical Implementation 69 | 70 | 71 | The frontend uses a configuration loader that fetches the `config.json` file and provides access to the configuration values through React hooks and utility functions. The configuration is loaded when the application starts and is cached for subsequent access. 72 | 73 | If the configuration file cannot be loaded, the application will display an error message to the user, indicating that the `config.json` file is required. 74 | 75 | -------------------------------------------------------------------------------- /frontend/src/hooks/useStreamingChat.js: -------------------------------------------------------------------------------- 1 | import { useState, useCallback, useRef } from 'react' 2 | import { useSession } from './chat/useSession' 3 | import { useMessages } from './chat/useMessages' 4 | import { ChatService } from '../services/chatService' 5 | 6 | export const useStreamingChat = () => { 7 | const [isLoading, setIsLoading] = useState(false) 8 | const [error, setError] = useState(null) 9 | const { sessionId, startNewSession } = useSession() 10 | const { messages, addMessage, updateLastMessage, resetMessages } = useMessages() 11 | const shouldStopRef = useRef(false) 12 | 13 | const stopAnswering = useCallback(() => { 14 | shouldStopRef.current = true; 15 | }, []); 16 | 17 | const sendMessage = async (chatRequest) => { 18 | setIsLoading(true) 19 | setError(null) 20 | shouldStopRef.current = false; 21 | 22 | try { 23 | addMessage({ role: 'user', content: chatRequest.message }) 24 | 25 | const response = await ChatService.sendMessage(chatRequest) 26 | 27 | if (response.isRateLimit) { 28 | addMessage({ 29 | role: 'assistant', 30 | content: response.message, 31 | isError: true, 32 | retryAfter: response.retryAfter 33 | }) 34 | setIsLoading(false) 35 | return 36 | } 37 | 38 | let hasStartedStreaming = false 39 | 40 | await ChatService.handleStreamingResponse(response, { 41 | onChunk: (content) => { 42 | if (!hasStartedStreaming) { 43 | addMessage({ role: 'assistant', content, isTyping: true }) 44 | hasStartedStreaming = true 45 | } else { 46 | updateLastMessage(content, true) 47 | } 48 | }, 49 | onComplete: (content) => { 50 | if (!hasStartedStreaming && content) { 51 | addMessage({ role: 'assistant', content, isTyping: false }) 52 | } else if (hasStartedStreaming) { 53 | updateLastMessage(content, false) 54 | } else { 55 | addMessage({ role: 'assistant', content: 'I apologize, but I couldn\'t generate a response.', isTyping: false }) 56 | } 57 | }, 58 | shouldStop: () => shouldStopRef.current 59 | }) 60 | } catch (err) { 61 | setError(err.message) 62 | } finally { 63 | setIsLoading(false) 64 | shouldStopRef.current = false; 65 | } 66 | } 67 | 68 | const startNewChat = useCallback(() => { 69 | const newSessionId = startNewSession() 70 | resetMessages() 71 | return newSessionId 72 | }, [startNewSession, resetMessages]) 73 | 74 | return { 75 | messages, 76 | isLoading, 77 | error, 78 | sendMessage, 79 | sessionId, 80 | startNewChat, 81 | stopAnswering 82 | } 83 | } -------------------------------------------------------------------------------- /frontend/src/components/sections/Home/ChatInput.jsx: -------------------------------------------------------------------------------- 1 | import { motion, AnimatePresence } from 'framer-motion'; 2 | import { Send, Square } from 'lucide-react'; 3 | import { getChatConfig } from '../../../config/configLoader'; 4 | 5 | export const ChatInput = ({ input, setInput, isLoading, onSubmit, onStop }) => { 6 | const chatConfig = getChatConfig(); 7 | const placeholder = chatConfig?.inputPlaceholder || 'Ask me anything...'; 8 | 9 | const handleSubmit = (e) => { 10 | e.preventDefault(); 11 | if (!input.trim() || isLoading) return; 12 | onSubmit(input.trim()); 13 | setInput(''); 14 | }; 15 | 16 | return ( 17 | 21 | setInput(e.target.value)} 25 | placeholder={placeholder} 26 | className="flex-1 min-w-0 bg-transparent text-white rounded-lg px-4 py-2 focus:outline-none placeholder-gray-400 overflow-hidden text-ellipsis whitespace-nowrap" 27 | disabled={isLoading} 28 | /> 29 | 30 | 31 | {isLoading ? ( 32 | 43 | 44 | 45 | ) : ( 46 | 57 | 58 | 59 | )} 60 | 61 | 62 | ); 63 | }; -------------------------------------------------------------------------------- /backend/tests/controllers/test_chat_router.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi.responses import StreamingResponse 3 | import time 4 | from unittest.mock import Mock 5 | from fastapi import HTTPException 6 | 7 | from app.models.data_structures import ChatRequest, Message 8 | 9 | 10 | # Fixtures 11 | @pytest.fixture 12 | def chat_request(): 13 | """Fixture for creating a valid ChatRequest object""" 14 | return ChatRequest( 15 | message="Hello", 16 | messages=[Message(role="user", content="Hello")], 17 | session_id="test-session", 18 | timestamp=time.time() 19 | ) 20 | 21 | # Tests 22 | @pytest.mark.unit 23 | async def test_home_endpoint(chat_router): 24 | """Test the home endpoint returns correct response""" 25 | response = await chat_router._home() 26 | assert response == {"Data": "Working!"} 27 | 28 | @pytest.mark.unit 29 | async def test_health_check_endpoint(chat_router): 30 | """Test the health check endpoint returns correct response""" 31 | response = await chat_router._health_check() 32 | assert response == {"status": "healthy"} 33 | 34 | @pytest.mark.unit 35 | async def test_chat_endpoint_success(chat_router, mock_chat_service, chat_request): 36 | """Test successful chat endpoint response""" 37 | async def mock_stream(): 38 | yield b"data: test\n\n" 39 | mock_chat_service.stream_chat.return_value = mock_stream() 40 | 41 | response = await chat_router._chat(chat_request) 42 | 43 | assert isinstance(response, StreamingResponse) 44 | assert response.media_type == "text/event-stream" 45 | mock_chat_service.stream_chat.assert_called_once_with(chat_request) 46 | 47 | @pytest.mark.unit 48 | async def test_chat_endpoint_unexpected_error(chat_router, mock_chat_service, chat_request): 49 | """Test chat endpoint handles unexpected errors correctly""" 50 | mock_chat_service.stream_chat = Mock(side_effect=Exception("Unexpected error")) 51 | 52 | with pytest.raises(HTTPException) as exc_info: 53 | await chat_router._chat(chat_request) 54 | 55 | assert exc_info.value.status_code == 500 56 | assert exc_info.value.detail == "An internal server error occurred." 57 | 58 | @pytest.mark.unit 59 | def test_router_initialization(chat_router): 60 | """Test that routes are properly added during initialization""" 61 | routes = chat_router.router.routes 62 | assert len(routes) == 3 63 | 64 | route_paths = {route.path for route in routes} 65 | assert route_paths == {"/", "/health", "/chat"} 66 | 67 | route_methods = { 68 | route.path: [method for method in route.methods] 69 | for route in routes 70 | } 71 | assert route_methods["/"] == ["GET"] 72 | assert route_methods["/health"] == ["GET"] 73 | assert route_methods["/chat"] == ["POST"] -------------------------------------------------------------------------------- /frontend/src/components/sections/Home/ChatMessage.jsx: -------------------------------------------------------------------------------- 1 | import { Bot, User } from 'lucide-react'; 2 | import { motion } from 'framer-motion'; 3 | import ReactMarkdown from 'react-markdown'; 4 | import { RateLimitCountdown } from './RateLimitCountdown'; 5 | import { TypingIndicator } from './TypingIndicator'; 6 | 7 | export const ChatMessage = ({ message }) => { 8 | // If this is a typing message from the assistant with no content, render the standalone TypingIndicator 9 | if (message.role === 'assistant' && message.isTyping && !message.content) { 10 | return ; 11 | } 12 | 13 | return ( 14 | 21 |
22 | {message.role === 'assistant' && } 23 |
24 | 25 | {message.isError && message.retryAfter > 0 && ( 26 | 27 | )} 28 |
29 | {message.role === 'user' && } 30 |
31 |
32 | ); 33 | }; 34 | 35 | const MessageAvatar = ({ icon: Icon }) => ( 36 |
37 | 38 |
39 | ); 40 | 41 | const MessageContent = ({ message }) => ( 42 |
51 | {message.role === 'user' ? ( 52 | 53 | ) : ( 54 | 55 | )} 56 |
57 | ); 58 | 59 | const UserMessage = ({ content }) => ( 60 |

61 | {content} 62 |

63 | ); 64 | 65 | const AssistantMessage = ({ message }) => ( 66 |
67 | 81 | {message.content} 82 | 83 | {message.isTyping && } 84 |
85 | ); -------------------------------------------------------------------------------- /frontend/src/hooks/chat/useSession.test.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; 2 | import { renderHook, act } from '@testing-library/react'; 3 | import { useSession } from './useSession'; 4 | import { createLocalStorageMock } from '../../test/testUtils.jsx'; // Import from testUtils.jsx 5 | import '../../test/mocks'; // Import global mocks which includes uuid mock 6 | 7 | // Mock uuid module - must be before any imports that use uuid 8 | vi.mock('uuid', () => ({ 9 | v4: () => 'test-uuid-1234' 10 | })); 11 | 12 | describe('useSession', () => { 13 | // Use the common localStorage mock utility 14 | const localStorageMock = createLocalStorageMock(); 15 | 16 | beforeEach(() => { 17 | // Setup mocks 18 | Object.defineProperty(window, 'localStorage', { value: localStorageMock }); 19 | localStorageMock.clear(); 20 | vi.clearAllMocks(); 21 | }); 22 | 23 | afterEach(() => { 24 | // Cleanup 25 | vi.restoreAllMocks(); 26 | }); 27 | 28 | it('initializes with a new session ID if none exists in localStorage', () => { 29 | // Ensure localStorage is empty 30 | expect(localStorageMock.getItem('chatSessionId')).toBeNull(); 31 | 32 | // Render the hook 33 | const { result } = renderHook(() => useSession()); 34 | 35 | // Check that a new session ID was generated 36 | expect(result.current.sessionId).toBe('test-uuid-1234'); 37 | 38 | // Check that the session ID was stored in localStorage 39 | expect(localStorageMock.setItem).toHaveBeenCalledWith('chatSessionId', 'test-uuid-1234'); 40 | }); 41 | 42 | it('uses existing session ID from localStorage if available', () => { 43 | // Set a session ID in localStorage 44 | localStorageMock.setItem('chatSessionId', 'existing-session-id'); 45 | 46 | // Render the hook 47 | const { result } = renderHook(() => useSession()); 48 | 49 | // Check that the existing session ID was used 50 | expect(result.current.sessionId).toBe('existing-session-id'); 51 | }); 52 | 53 | it('starts a new session when startNewSession is called', () => { 54 | // Set an existing session ID 55 | localStorageMock.setItem('chatSessionId', 'existing-session-id'); 56 | 57 | // Render the hook 58 | const { result } = renderHook(() => useSession()); 59 | 60 | // Initially should use the existing session ID 61 | expect(result.current.sessionId).toBe('existing-session-id'); 62 | 63 | // Call startNewSession 64 | let newSessionId; 65 | act(() => { 66 | newSessionId = result.current.startNewSession(); 67 | }); 68 | 69 | // Check that the session ID was updated 70 | expect(result.current.sessionId).toBe('test-uuid-1234'); 71 | 72 | // Check that the new session ID was stored in localStorage 73 | expect(localStorageMock.setItem).toHaveBeenCalledWith('chatSessionId', 'test-uuid-1234'); 74 | 75 | // Check that the function returns the new session ID 76 | expect(newSessionId).toBe('test-uuid-1234'); 77 | }); 78 | }); -------------------------------------------------------------------------------- /frontend/src/components/sections/Home/RateLimitCountdown.test.jsx: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' 2 | import { screen, act, cleanup } from '@testing-library/react' 3 | import { RateLimitCountdown } from './RateLimitCountdown' 4 | import React from 'react' 5 | import '../../../test/mocks' 6 | import { renderWithConfig } from '../../../test/testUtils' 7 | 8 | // Mock the Lucide React Clock icon 9 | vi.mock('lucide-react', () => ({ 10 | Clock: () => React.createElement('div', { 'data-testid': 'clock-icon' }) 11 | })) 12 | 13 | describe('RateLimitCountdown', () => { 14 | beforeEach(() => { 15 | // Setup fake timers 16 | vi.useFakeTimers() 17 | }) 18 | 19 | afterEach(() => { 20 | // Restore real timers 21 | vi.useRealTimers() 22 | }) 23 | 24 | it('renders the countdown with correct initial time', () => { 25 | renderWithConfig() 26 | expect(screen.getByText('Try again in 10 seconds')).toBeInTheDocument() 27 | expect(screen.getByTestId('clock-icon')).toBeInTheDocument() 28 | }) 29 | 30 | it('decrements the countdown every second', () => { 31 | renderWithConfig() 32 | 33 | // Initial state 34 | expect(screen.getByText('Try again in 3 seconds')).toBeInTheDocument() 35 | 36 | // Advance timer by 1 second 37 | act(() => { 38 | vi.advanceTimersByTime(1000) 39 | }) 40 | expect(screen.getByText('Try again in 2 seconds')).toBeInTheDocument() 41 | 42 | // Advance timer by another second 43 | act(() => { 44 | vi.advanceTimersByTime(1000) 45 | }) 46 | expect(screen.getByText('Try again in 1 seconds')).toBeInTheDocument() 47 | }) 48 | 49 | it('stops at zero and does not render when countdown reaches zero', () => { 50 | renderWithConfig() 51 | 52 | // Initial state 53 | expect(screen.getByText('Try again in 1 seconds')).toBeInTheDocument() 54 | 55 | // Advance timer to reach zero 56 | act(() => { 57 | vi.advanceTimersByTime(1000) 58 | }) 59 | 60 | // Component should not render anything when countdown reaches zero 61 | expect(screen.queryByText(/Try again in/)).not.toBeInTheDocument() 62 | expect(screen.queryByTestId('clock-icon')).not.toBeInTheDocument() 63 | }) 64 | 65 | it('does not render when seconds prop is 0 or negative', () => { 66 | renderWithConfig() 67 | expect(screen.queryByText(/Try again in/)).not.toBeInTheDocument() 68 | 69 | // Cleanup and re-render with negative value 70 | cleanup() 71 | renderWithConfig() 72 | expect(screen.queryByText(/Try again in/)).not.toBeInTheDocument() 73 | }) 74 | 75 | it('clears interval on unmount', () => { 76 | const clearIntervalSpy = vi.spyOn(global, 'clearInterval') 77 | const { unmount } = renderWithConfig() 78 | 79 | unmount() 80 | 81 | expect(clearIntervalSpy).toHaveBeenCalled() 82 | clearIntervalSpy.mockRestore() 83 | }) 84 | }) -------------------------------------------------------------------------------- /frontend/src/components/layout/NavBar/DesktopNav.test.jsx: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, beforeEach } from 'vitest'; 2 | import { screen } from '@testing-library/react'; 3 | import { DesktopNav } from './DesktopNav'; 4 | import React from 'react'; 5 | import '../../../test/mocks'; 6 | import { renderWithRouterAndConfig } from '../../../test/testUtils'; 7 | 8 | // Mock the navigationConfig module 9 | vi.mock('../config/navigationConfig', () => { 10 | return { 11 | sections: { 12 | about: { 13 | icon: vi.fn(() =>
About Icon
), 14 | title: 'About Me', 15 | path: '/about-me' 16 | }, 17 | projects: { 18 | icon: vi.fn(() =>
Projects Icon
), 19 | title: 'Projects', 20 | path: '/projects' 21 | }, 22 | blog: { 23 | icon: vi.fn(() =>
Blog Icon
), 24 | title: 'Blog', 25 | path: '/blog' 26 | } 27 | } 28 | }; 29 | }); 30 | 31 | // Mock the SocialLinks component 32 | vi.mock('../shared/SocialLinks', () => ({ 33 | SocialLinks: () =>
Social Links Mock
34 | })); 35 | 36 | // Mock react-router-dom 37 | vi.mock('react-router-dom', () => ({ 38 | NavLink: ({ to, children, className }) => { 39 | // Mock the isActive function that NavLink provides 40 | const isActive = to === '/about-me'; // Simulate 'About Me' being active 41 | return ( 42 | 47 | {children} 48 | 49 | ); 50 | } 51 | })); 52 | 53 | describe('DesktopNav', () => { 54 | beforeEach(() => { 55 | renderWithRouterAndConfig(); 56 | }); 57 | 58 | it('renders all navigation links', () => { 59 | expect(screen.getByText('About Me')).toBeInTheDocument(); 60 | expect(screen.getByText('Projects')).toBeInTheDocument(); 61 | expect(screen.getByText('Blog')).toBeInTheDocument(); 62 | }); 63 | 64 | it('renders icons for each navigation link', () => { 65 | expect(screen.getByTestId('about-icon')).toBeInTheDocument(); 66 | expect(screen.getByTestId('projects-icon')).toBeInTheDocument(); 67 | expect(screen.getByTestId('blog-icon')).toBeInTheDocument(); 68 | }); 69 | 70 | it('applies active class to the active link', () => { 71 | const aboutLink = screen.getByTestId('nav-link-aboutme'); 72 | const projectsLink = screen.getByTestId('nav-link-projects'); 73 | 74 | // About link should have the active class 75 | expect(aboutLink.className).toContain('bg-white/10'); 76 | expect(aboutLink.className).toContain('text-white'); 77 | 78 | // Projects link should have the inactive class 79 | expect(projectsLink.className).toContain('text-gray-400'); 80 | }); 81 | 82 | it('renders the SocialLinks component', () => { 83 | expect(screen.getByTestId('social-links-mock')).toBeInTheDocument(); 84 | }); 85 | }); -------------------------------------------------------------------------------- /frontend/src/services/github.test.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; 2 | import { fetchUserRepos } from './github'; 3 | 4 | describe('GitHub Service', () => { 5 | // Store the original fetch 6 | const originalFetch = global.fetch; 7 | 8 | beforeEach(() => { 9 | // Mock fetch before each test 10 | global.fetch = vi.fn(); 11 | }); 12 | 13 | afterEach(() => { 14 | // Restore fetch after each test 15 | global.fetch = originalFetch; 16 | vi.clearAllMocks(); 17 | }); 18 | 19 | describe('fetchUserRepos', () => { 20 | it('should fetch repos from the GitHub API with correct URL', async () => { 21 | // Mock successful response 22 | const mockRepos = [ 23 | { id: 1, name: 'repo1' }, 24 | { id: 2, name: 'repo2' } 25 | ]; 26 | 27 | const mockResponse = { 28 | ok: true, 29 | json: vi.fn().mockResolvedValue(mockRepos) 30 | }; 31 | 32 | global.fetch.mockResolvedValue(mockResponse); 33 | 34 | // Call the function 35 | const result = await fetchUserRepos('testuser'); 36 | 37 | // Verify fetch was called with the correct URL 38 | expect(global.fetch).toHaveBeenCalledWith('https://api.github.com/users/testuser/repos'); 39 | 40 | // Verify the response was processed correctly 41 | expect(result).toEqual(mockRepos); 42 | expect(mockResponse.json).toHaveBeenCalled(); 43 | }); 44 | 45 | it('should throw an error when the response is not ok', async () => { 46 | // Mock failed response 47 | const mockResponse = { 48 | ok: false, 49 | status: 404, 50 | statusText: 'Not Found' 51 | }; 52 | 53 | global.fetch.mockResolvedValue(mockResponse); 54 | 55 | // Call the function and expect it to throw 56 | await expect(fetchUserRepos('nonexistentuser')).rejects.toThrow('Failed to fetch repos'); 57 | 58 | // Verify fetch was called 59 | expect(global.fetch).toHaveBeenCalledWith('https://api.github.com/users/nonexistentuser/repos'); 60 | }); 61 | 62 | it('should throw an error when fetch fails', async () => { 63 | // Mock fetch rejection 64 | const networkError = new Error('Network error'); 65 | global.fetch.mockRejectedValue(networkError); 66 | 67 | // Call the function and expect it to throw 68 | await expect(fetchUserRepos('testuser')).rejects.toThrow('Network error'); 69 | 70 | // Verify fetch was called 71 | expect(global.fetch).toHaveBeenCalledWith('https://api.github.com/users/testuser/repos'); 72 | }); 73 | 74 | it('should handle empty response', async () => { 75 | // Mock empty response 76 | const mockResponse = { 77 | ok: true, 78 | json: vi.fn().mockResolvedValue([]) 79 | }; 80 | 81 | global.fetch.mockResolvedValue(mockResponse); 82 | 83 | // Call the function 84 | const result = await fetchUserRepos('emptyuser'); 85 | 86 | // Verify the response was processed correctly 87 | expect(result).toEqual([]); 88 | expect(mockResponse.json).toHaveBeenCalled(); 89 | }); 90 | }); 91 | }); -------------------------------------------------------------------------------- /frontend/src/components/sections/About/index.jsx: -------------------------------------------------------------------------------- 1 | import { motion, AnimatePresence } from 'framer-motion' 2 | import { FastForward } from 'lucide-react' 3 | import { useState, useEffect } from 'react' 4 | import { useTypingEffect } from '../../../hooks/useTypingEffect' 5 | 6 | export const AboutSection = () => { 7 | const [aboutContent, setAboutContent] = useState('') 8 | const { displayedContent, setIsTyping, setDisplayedContent } = useTypingEffect(aboutContent) 9 | const [showSkipButton, setShowSkipButton] = useState(false) 10 | 11 | useEffect(() => { 12 | const fetchAboutMe = async () => { 13 | try { 14 | const response = await fetch('/about-me.md') 15 | const text = await response.text() 16 | setAboutContent(text) 17 | } catch (error) { 18 | console.error('Error reading about-me.md:', error) 19 | setAboutContent('Error loading content...') 20 | } 21 | } 22 | fetchAboutMe() 23 | }, []) 24 | 25 | useEffect(() => { 26 | const timer = setTimeout(() => { 27 | setShowSkipButton(true) 28 | }, 2000) 29 | 30 | return () => clearTimeout(timer) 31 | }, []) 32 | 33 | const handleSkip = () => { 34 | setIsTyping(false) 35 | setDisplayedContent(aboutContent) 36 | setShowSkipButton(false) 37 | } 38 | 39 | return ( 40 | 47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 | 55 | ~/about-me 56 | cat about-me.md 57 |
58 | {displayedContent} 59 | 60 |
61 |
62 |
63 | 64 | 65 | {showSkipButton && displayedContent !== aboutContent && ( 66 | 77 | 78 | Skip Typing 79 | 80 | )} 81 | 82 | 83 | ) 84 | } -------------------------------------------------------------------------------- /frontend/src/components/sections/Home/TypingIndicator.test.jsx: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi } from 'vitest'; 2 | import { screen } from '@testing-library/react'; 3 | import { TypingIndicator } from './TypingIndicator'; 4 | import React from 'react'; 5 | import '../../../test/mocks'; 6 | import { renderWithConfig } from '../../../test/testUtils'; 7 | 8 | // Mock the Lucide React icons 9 | vi.mock('lucide-react', () => ({ 10 | Bot: () => React.createElement('div', { 'data-testid': 'bot-icon' }) 11 | })); 12 | 13 | // Mock framer-motion to avoid animation issues in tests 14 | vi.mock('framer-motion', () => ({ 15 | motion: { 16 | div: ({ children, ...props }) => ( 17 |
18 | {children} 19 |
20 | ), 21 | }, 22 | AnimatePresence: ({ children }) => <>{children}, 23 | })); 24 | 25 | describe('TypingIndicator', () => { 26 | it('renders the full typing indicator with avatar when inline=false', () => { 27 | const { container } = renderWithConfig(); 28 | 29 | // Check that the component renders 30 | expect(screen.getByTestId('ai-typing-indicator')).toBeInTheDocument(); 31 | 32 | // Check that the bot icon is displayed 33 | expect(screen.getByTestId('bot-icon')).toBeInTheDocument(); 34 | 35 | // Check for the animation dots 36 | const animationDots = container.querySelectorAll('.animate-bounce'); 37 | expect(animationDots.length).toBe(3); 38 | 39 | // Check for the purple styling on the dots 40 | const purpleDots = container.querySelectorAll('.bg-purple-400'); 41 | expect(purpleDots.length).toBe(3); 42 | 43 | // Check for the message bubble styling 44 | const messageBubble = container.querySelector('.bg-gray-800\\/80'); 45 | expect(messageBubble).toBeInTheDocument(); 46 | }); 47 | 48 | it('renders just the dots when inline=true', () => { 49 | const { container } = renderWithConfig(); 50 | 51 | // Check that the inline component renders 52 | expect(screen.getByTestId('typing-indicator-inline')).toBeInTheDocument(); 53 | 54 | // Check that the bot icon is NOT displayed 55 | expect(screen.queryByTestId('bot-icon')).not.toBeInTheDocument(); 56 | 57 | // Check for the animation dots 58 | const animationDots = container.querySelectorAll('.animate-bounce'); 59 | expect(animationDots.length).toBe(3); 60 | 61 | // Check for the white styling on the dots (inline uses white) 62 | const whiteDots = container.querySelectorAll('.bg-white'); 63 | expect(whiteDots.length).toBe(3); 64 | 65 | // Check that the message bubble is NOT present 66 | const messageBubble = container.querySelector('.bg-gray-800\\/80'); 67 | expect(messageBubble).not.toBeInTheDocument(); 68 | }); 69 | 70 | it('applies correct animation delays to the dots', () => { 71 | const { container } = renderWithConfig(); 72 | 73 | // Get all the dots 74 | const dots = container.querySelectorAll('.animate-bounce'); 75 | 76 | // Check that each dot has a different animation delay 77 | expect(dots[0].style.animationDelay).toBe('0s'); 78 | expect(dots[1].style.animationDelay).toBe('0.15s'); 79 | expect(dots[2].style.animationDelay).toBe('0.3s'); 80 | }); 81 | }); -------------------------------------------------------------------------------- /backend/docs/example_resume.md: -------------------------------------------------------------------------------- 1 | ALON TRUGMAN - WORK EXPERIENCE 2 | ### Wix.com - Backend Engineer *Sep 2023 - Sep 2024* 3 | Focused on Git DevEx infra that increases cadence and security across the company. 4 | * Refactored a suite of Scala services from scratch while maintaining backward compatibility, which eliminated human errors and increased security by creating new gRPC APIs, utilizing MySQL, monitoring with Grafana & Prometheus, and production debugging using Rookout. 5 | * Integrated with external services via webhooks, converted them to Kafka events, processed, stored, and handled data at scale, while using OAuth for authorization. 6 | 7 | ### Planckdata (Acquired by Applied Systems) - Backend Engineer *Dec 2021 - Sep 2023* 8 | We provided platform for insurance underwriters powered by public data, ML and LLMs. 9 | * Led a cost evaluation initiative that resulted in a 28% cost reduction by monitoring and analyzing external API calls using Java, RabbitMQ, Airflow DAGs, S3, and Postgres. 10 | * Designed and implemented a Data Quality project yielding a 20% boost in quality and improved end-user satisfaction by tracking data collection issues using Java, RabbitMQ & DynamoDB. 11 | * Developed a load test for the data collection system resulting in a 10% increase in speed by identifying bottlenecks and improving efficiency using Python and RESTful APIs. 12 | * Migrated Elasticsearch and Kibana cluster from Elastic to AWS OpenSearch. 13 | * Worked with our Kubernetes cluster, proficient in Docker and Terraform, and implemented CI/CD pipelines using GitLab for streamlined deployment processes. 14 | 15 | ### Planckdata (Acquired by Applied Systems) - Solutions Data Analyst *Feb 2021 - Dec 2021* 16 | * Transformed customer usage data into actionable insights, wrote automation scripts using Python over Elasticsearch, Monday & Google Sheets and analyzed product performance. 17 | * Collaborated with Sales, CS, and Product teams for optimal sales cycle execution and often joined customer meetings. Prepared concise reports for clients and management. 18 | 19 | ### Beilinson Hospital - Data Science Research Assistant *Jul 2020 - Jul 2021* 20 | * Selected as one of two students excelling in the Data Science course. 21 | * Established the Python infrastructure that analyzes ICU patient nutritional data using Jupyter Notebook, Pandas, Scikit-learn and Pycaret. 22 | * Focused on predicting mortality chances, I conducted extensive exploratory data analysis, data preprocessing, and executed ML models to predict and evaluate potential patient events. 23 | 24 | ### SafeHouse Technologies - Product Manager *Jul 2019 - Jul 2020* 25 | * Managed the implementation of Mixpanel analytics, enabling data-driven decision-making. 26 | * Collaborated with designers to work on the product's UI/UX using Figma & InVision. 27 | * Created a marketing plan utilizing Firebase and Google Ads. 28 | * Shaped the long-term vision and strategy for the company's product and features. 29 | 30 | ### EDUCATION 31 | * B.SC In Industrial Engineering & Management | Ariel University *Jul 2017 - Jul 2021* 32 | * Major in Data science | Graduated with Distinction (Magna Cum Laude) 33 | 34 | ### ADDITIONAL INFORMATION 35 | * Army Duty: Shayetet 13, Combat Fighter on course release due to medical reasons 36 | * Volunteering: Mentor in Kravi-Tech & Genesis, guide people in finding a job in Hightech plus activist in israel 2050 movement 37 | * Activities: Participated in TAU debate club -------------------------------------------------------------------------------- /frontend/src/components/sections/Home/IntroSection.jsx: -------------------------------------------------------------------------------- 1 | import { motion } from 'framer-motion'; 2 | import { BookOpen, Brain, MessageSquareText } from 'lucide-react'; 3 | import { getContentConfig } from '../../../config/configLoader'; 4 | 5 | export const IntroSection = () => { 6 | const contentConfig = getContentConfig(); 7 | const content = contentConfig?.intro || { 8 | cards: [], 9 | paragraphs: [] 10 | }; 11 | 12 | // Map of icon components by name 13 | const iconMap = { 14 | Brain, 15 | BookOpen, 16 | MessageSquareText 17 | }; 18 | 19 | // Gradient classes for cards 20 | const gradientClasses = [ 21 | "before:from-rose-500/10 before:via-purple-500/15 before:to-purple-500/10", 22 | "before:from-blue-500/10 before:via-cyan-500/15 before:to-blue-500/10", 23 | "before:from-emerald-500/10 before:via-teal-500/15 before:to-emerald-500/10" 24 | ]; 25 | 26 | return ( 27 |
28 |
29 | {/* Feature cards */} 30 |
31 | {content.cards.map((card, index) => { 32 | const Icon = iconMap[card.icon] || Brain; 33 | const gradientClass = gradientClasses[index % gradientClasses.length]; 34 | 35 | return ( 36 | 46 | {/* Card content */} 47 |
48 |
49 | 50 |
51 |

52 | {card.title} 53 |

54 |

55 | {card.description} 56 |

57 |
58 |
59 | ); 60 | })} 61 |
62 | 63 | {/* Content section */} 64 |
65 | {content.paragraphs.map((paragraph, index) => ( 66 |

72 | {paragraph} 73 |

74 | ))} 75 |
76 |
77 |
78 | ); 79 | }; -------------------------------------------------------------------------------- /backend/README.md: -------------------------------------------------------------------------------- 1 | # Stand Alone Backend Service 🔧 2 | 3 | [![Python](https://img.shields.io/badge/Python-3.12-blue?style=for-the-badge&logo=python)](https://www.python.org/) 4 | [![FastAPI](https://img.shields.io/badge/FastAPI-005571?style=for-the-badge&logo=fastapi)](https://fastapi.tiangolo.com/) 5 | [![PostgreSQL](https://img.shields.io/badge/PostgreSQL-316192?style=for-the-badge&logo=postgresql&logoColor=white)](https://www.postgresql.org/) 6 | [![Redis](https://img.shields.io/badge/Redis-DC382D?style=for-the-badge&logo=redis&logoColor=white)](https://redis.io/) 7 | 8 | ## 🚀 Local Setup Instructions 9 | 10 | 1. Create venv for the project using python 3.12: 11 | ```bash 12 | pipenv shell 13 | pipenv install 14 | ``` 15 | 2. Create an `.env` file and update the envars (look at env.example file in the root dir) 16 | 17 | 3. Build the postgres image: 18 | ```bash 19 | # From the backend directory 20 | docker build -t fly-postgres-pgvector -f Dockerfile.postgres . 21 | docker run -d --name fly-postgres-rag -p 5432:5432 -e POSTGRES_DB=pgdb -e POSTGRES_USER=pguser -e POSTGRES_PASSWORD=docker fly-postgres-pgvector 22 | ``` 23 | 24 | 4. Create a local docker container for redis: 25 | ```bash 26 | docker run -d --name redis-db -p 6379:6379 redis:7-alpine 27 | ``` 28 | 29 | 5. Add your CV and other text you want to use for the RAG to the "docs" folder as .md files 30 | 31 | 6. Start the server: 32 | ```bash 33 | python3 run_server.py 34 | ``` 35 | 36 | 7. Visit http://localhost:8000/docs and enjoy! 🎉 37 | 38 | ## 🐳 Docker Compose 39 | 40 | ```bash 41 | # Build the containers 42 | docker compose build 43 | 44 | # Start the services 45 | docker compose up 46 | ``` 47 | 48 | ## 🚀 Deployment on fly.io 49 | 50 | 1. Install the Fly CLI: 51 | ```bash 52 | brew install flyctl 53 | ``` 54 | 55 | 2. Create a new app: 56 | ```bash 57 | fly launch --no-deploy --name 58 | ``` 59 | 60 | 3. Create Postgres database: 61 | ```bash 62 | fly postgres create --image-ref andrebrito16/pgvector-fly --name 63 | ``` 64 | > Note: Using a community image with pgvector extension pre-installed. 65 | > [Learn more here](https://andrefbrito.medium.com/how-to-add-pgvector-support-on-fly-io-postgres-35b2ca039ab8) 66 | 67 | 4. Create Redis instance: 68 | ```bash 69 | fly redis create --name 70 | ``` 71 | 72 | 5. Set required secrets: 73 | ```bash 74 | fly secrets set POSTGRES_PASSWORD= 75 | fly secrets set LLM_ROUTER_API_KEY= 76 | fly secrets set OPENAI_API_KEY= 77 | fly secrets set REDIS_URL= 78 | ``` 79 | 80 | 6. Update the `fly.toml` file with your app, postgres, and redis details 81 | 82 | 7. Deploy: 83 | ```bash 84 | fly deploy 85 | ``` 86 | 87 | 8. Visit your app at https://your-app-name.fly.dev/docs 🎉 88 | 89 | ## 🧪 Testing 90 | 91 | This project uses pytest for testing. Tests are organized in the `tests/` directory following the application structure. 92 | 93 | ### Running Tests 94 | 95 | ```bash 96 | # Run all tests 97 | pytest 98 | 99 | # Run tests with coverage report 100 | pytest --cov=app 101 | 102 | # Run specific test file 103 | pytest tests/path/to/test_file.py 104 | 105 | # Run tests in watch mode 106 | pytest-watch 107 | ``` 108 | 109 | ### Test Configuration 110 | 111 | The test configuration is defined in `pytest.ini`. Test environment variables defined in the test fixtures. 112 | 113 | -------------------------------------------------------------------------------- /frontend/src/components/layout/NavBar/index.test.jsx: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, beforeEach } from 'vitest'; 2 | import { screen, fireEvent } from '@testing-library/react'; 3 | import { NavBar } from './index'; 4 | import React from 'react'; 5 | import '../../../test/mocks'; 6 | import { renderWithRouterAndConfig } from '../../../test/testUtils'; 7 | 8 | // Mock the child components 9 | vi.mock('./DesktopNav', () => ({ 10 | DesktopNav: () =>
Desktop Nav Mock
11 | })); 12 | 13 | vi.mock('./MobileNav', () => ({ 14 | MobileNav: ({ isMenuOpen, setIsMenuOpen }) => ( 15 |
16 | Mobile Nav Mock 17 | 23 |
{isMenuOpen ? 'Open' : 'Closed'}
24 |
25 | ) 26 | })); 27 | 28 | // Mock react-router-dom 29 | vi.mock('react-router-dom', () => ({ 30 | Link: ({ to, children, className }) => ( 31 | 32 | {children} 33 | 34 | ) 35 | })); 36 | 37 | // Mock lucide-react icons 38 | vi.mock('lucide-react', () => ({ 39 | Terminal: () =>
Terminal Icon
, 40 | Menu: () =>
Menu Icon
, 41 | X: () =>
X Icon
42 | })); 43 | 44 | describe('NavBar', () => { 45 | beforeEach(() => { 46 | vi.clearAllMocks(); 47 | }); 48 | 49 | it('renders the logo and site name', () => { 50 | renderWithRouterAndConfig(); 51 | 52 | expect(screen.getByTestId('terminal-icon')).toBeInTheDocument(); 53 | expect(screen.getByText('Alon.dev')).toBeInTheDocument(); 54 | }); 55 | 56 | it('renders the desktop navigation', () => { 57 | renderWithRouterAndConfig(); 58 | 59 | expect(screen.getByTestId('desktop-nav-mock')).toBeInTheDocument(); 60 | }); 61 | 62 | it('renders the mobile navigation', () => { 63 | renderWithRouterAndConfig(); 64 | 65 | expect(screen.getByTestId('mobile-nav-mock')).toBeInTheDocument(); 66 | }); 67 | 68 | it('toggles the mobile menu when the menu button is clicked', () => { 69 | renderWithRouterAndConfig(); 70 | 71 | // Initially the menu should be closed 72 | expect(screen.getByTestId('menu-state')).toHaveTextContent('Closed'); 73 | 74 | // Click the menu button 75 | const menuButton = screen.getByRole('button', { name: /toggle menu/i }); 76 | fireEvent.click(menuButton); 77 | 78 | // Now the menu should be open 79 | expect(screen.getByTestId('menu-state')).toHaveTextContent('Open'); 80 | 81 | // Click the menu button again 82 | fireEvent.click(menuButton); 83 | 84 | // Now the menu should be closed again 85 | expect(screen.getByTestId('menu-state')).toHaveTextContent('Closed'); 86 | }); 87 | 88 | it('renders the menu icon when menu is closed', () => { 89 | renderWithRouterAndConfig(); 90 | 91 | // Initially the menu should be closed and the menu icon should be visible 92 | expect(screen.getByTestId('menu-icon')).toBeInTheDocument(); 93 | }); 94 | 95 | it('has a link to the home page', () => { 96 | renderWithRouterAndConfig(); 97 | 98 | const homeLink = screen.getByTestId('router-link'); 99 | expect(homeLink).toHaveAttribute('href', '/'); 100 | }); 101 | }); -------------------------------------------------------------------------------- /frontend/src/test/setup.js: -------------------------------------------------------------------------------- 1 | import { expect, afterEach, beforeAll, afterAll, vi } from 'vitest' 2 | import { cleanup } from '@testing-library/react' 3 | import * as matchers from '@testing-library/jest-dom/matchers' 4 | import mockConfig from './mocks/config.json' 5 | 6 | // Extend Vitest's expect method with methods from react-testing-library 7 | expect.extend(matchers) 8 | 9 | // Clean up after each test case (e.g., clearing jsdom) 10 | afterEach(() => { 11 | cleanup() 12 | }) 13 | 14 | // Mock configLoader before any imports that might use it 15 | vi.mock('../config/configLoader', () => { 16 | return { 17 | loadConfig: vi.fn().mockResolvedValue(mockConfig), 18 | getConfig: vi.fn((section, key, defaultValue) => { 19 | if (!section) return mockConfig; 20 | if (!key) return mockConfig[section] || defaultValue; 21 | return mockConfig[section]?.[key] || defaultValue; 22 | }), 23 | getPersonalInfo: vi.fn(() => mockConfig.personal), 24 | getSocialLinks: vi.fn(() => mockConfig.social), 25 | getChatConfig: vi.fn(() => mockConfig.chat), 26 | getContentConfig: vi.fn(() => mockConfig.content) 27 | } 28 | }) 29 | 30 | // Mock ConfigProvider 31 | vi.mock('../config/ConfigProvider', () => { 32 | const React = require('react') 33 | const ConfigContext = React.createContext({ 34 | config: mockConfig, 35 | loading: false, 36 | error: null 37 | }) 38 | 39 | return { 40 | ConfigProvider: ({ children }) => 41 | React.createElement(ConfigContext.Provider, 42 | { value: { config: mockConfig, loading: false, error: null } }, 43 | children 44 | ), 45 | useConfig: () => React.useContext(ConfigContext) 46 | } 47 | }) 48 | 49 | // Mock fetch for config.json 50 | beforeAll(() => { 51 | // Mock fetch to return the mock config for config.json requests 52 | global.fetch = vi.fn((url) => { 53 | if (url === '/config.json') { 54 | return Promise.resolve({ 55 | ok: true, 56 | json: () => Promise.resolve(mockConfig) 57 | }) 58 | } 59 | 60 | // For other requests, return a 404 61 | return Promise.resolve({ 62 | ok: false, 63 | status: 404, 64 | statusText: 'Not Found' 65 | }) 66 | }) 67 | }) 68 | 69 | // Suppress React DOM prop warnings (especially useful for framer-motion props) 70 | const originalError = console.error 71 | beforeAll(() => { 72 | console.error = (...args) => { 73 | if (/Warning.*React does not recognize the.*prop on a DOM element/.test(args[0])) { 74 | return 75 | } 76 | // Suppress act warnings in tests 77 | if (/Warning.*not wrapped in act/.test(args[0])) { 78 | return 79 | } 80 | originalError.call(console, ...args) 81 | } 82 | }) 83 | 84 | afterAll(() => { 85 | console.error = originalError 86 | // Reset fetch mock 87 | vi.restoreAllMocks() 88 | }) 89 | 90 | // Mock react-router-dom 91 | vi.mock('react-router-dom', async () => { 92 | const actual = await vi.importActual('react-router-dom'); 93 | const React = require('react'); 94 | 95 | // Create a mock BrowserRouter that just renders its children 96 | const BrowserRouter = ({ children }) => React.createElement('div', { 'data-testid': 'browser-router' }, children); 97 | 98 | return { 99 | ...actual, 100 | BrowserRouter, 101 | useNavigate: vi.fn().mockReturnValue(vi.fn()), 102 | useLocation: vi.fn().mockReturnValue({ pathname: '/' }), 103 | useParams: vi.fn().mockReturnValue({}), 104 | Link: ({ to, children, ...props }) => React.createElement('a', { href: to, ...props }, children) 105 | }; 106 | }); -------------------------------------------------------------------------------- /frontend/src/test/mocks.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { vi } from 'vitest'; 3 | import { createLocalStorageMock } from './testUtils.jsx'; 4 | import mockConfig from './mocks/config.json'; 5 | 6 | /** 7 | * Common mock for framer-motion 8 | * Import this in your test files with: 9 | * import '../test/mocks'; 10 | */ 11 | vi.mock('framer-motion', () => ({ 12 | motion: { 13 | div: ({ children, ...props }) => React.createElement('div', props, children), 14 | a: ({ children, ...props }) => React.createElement('a', props, children), 15 | form: ({ children, ...props }) => React.createElement('form', props, children), 16 | button: ({ children, ...props }) => React.createElement('button', props, children), 17 | textarea: ({ children, ...props }) => React.createElement('textarea', props, children), 18 | span: ({ children, ...props }) => React.createElement('span', props, children), 19 | p: ({ children, ...props }) => React.createElement('p', props, children), 20 | h1: ({ children, ...props }) => React.createElement('h1', props, children), 21 | h2: ({ children, ...props }) => React.createElement('h2', props, children), 22 | h3: ({ children, ...props }) => React.createElement('h3', props, children), 23 | ul: ({ children, ...props }) => React.createElement('ul', props, children), 24 | li: ({ children, ...props }) => React.createElement('li', props, children), 25 | // Add other elements as needed 26 | }, 27 | AnimatePresence: ({ children }) => React.createElement(React.Fragment, null, children) 28 | })); 29 | 30 | // Mock for ReactMarkdown 31 | vi.mock('react-markdown', () => ({ 32 | default: ({ children }) => React.createElement('div', { className: 'markdown-content' }, children) 33 | })); 34 | 35 | // Mock for uuid 36 | vi.mock('uuid', () => ({ 37 | v4: () => 'test-uuid-1234' 38 | })); 39 | 40 | // Mock for Vercel Analytics 41 | vi.mock('@vercel/analytics', () => ({ 42 | inject: vi.fn() 43 | })); 44 | 45 | // Mock for Element.scrollIntoView 46 | if (typeof Element !== 'undefined') { 47 | Element.prototype.scrollIntoView = vi.fn(); 48 | } 49 | 50 | // Export localStorage mock for reuse 51 | export const localStorageMock = createLocalStorageMock(); 52 | 53 | // Setup localStorage mock by default 54 | Object.defineProperty(window, 'localStorage', { value: localStorageMock }); 55 | 56 | // Mock for fetch 57 | global.fetch = vi.fn(); 58 | 59 | // Mock for console methods to reduce noise in tests 60 | const originalConsoleError = console.error; 61 | const originalConsoleWarn = console.warn; 62 | 63 | // Helper to restore console methods 64 | export const restoreConsoleMethods = () => { 65 | console.error = originalConsoleError; 66 | console.warn = originalConsoleWarn; 67 | }; 68 | 69 | // Helper to suppress console methods 70 | export const suppressConsoleMethods = () => { 71 | console.error = vi.fn(); 72 | console.warn = vi.fn(); 73 | }; 74 | 75 | // Helper to create common hook mocks 76 | export const createHookMocks = () => ({ 77 | useStreamingChat: vi.fn(() => ({ 78 | isLoading: false, 79 | error: null, 80 | sendMessage: vi.fn(async () => ({ success: true })), 81 | startNewChat: vi.fn(), 82 | stopAnswering: vi.fn(), 83 | messages: [{ role: 'assistant', content: 'Hello! How can I help you today?' }], 84 | sessionId: 'test-session-id' 85 | })), 86 | useMessages: vi.fn(() => ({ 87 | messages: [], 88 | addMessage: vi.fn(), 89 | updateLastMessage: vi.fn(), 90 | resetMessages: vi.fn() 91 | })), 92 | useSession: vi.fn(() => ({ 93 | sessionId: 'test-session-id', 94 | startNewSession: vi.fn(() => 'new-test-session-id') 95 | })), 96 | useGithubRepos: vi.fn(() => ({ 97 | repos: [], 98 | loading: false, 99 | error: null 100 | })), 101 | useTypingEffect: vi.fn((content) => ({ 102 | displayedContent: content, 103 | setIsTyping: vi.fn(), 104 | setDisplayedContent: vi.fn() 105 | })) 106 | }); -------------------------------------------------------------------------------- /backend/app/factory.py: -------------------------------------------------------------------------------- 1 | from functools import lru_cache 2 | import os 3 | from fastapi import FastAPI 4 | from fastapi.middleware.cors import CORSMiddleware 5 | from app.db.db_handler import DatabaseHandler, PostgresConfig 6 | from openai import OpenAI 7 | from app.logic.chat_service import ChatService 8 | from langchain_openai import OpenAIEmbeddings 9 | from app.logic.document_indexer import DocumentIndexer 10 | from redis import Redis 11 | from app.middleware.rate_limiter import RateLimiter 12 | from app.middleware.rate_limit_middleware import RateLimitMiddleware 13 | from slowapi import Limiter 14 | from slowapi.util import get_remote_address 15 | 16 | def create_app() -> FastAPI: 17 | """Creates and configures the FastAPI application with all middleware""" 18 | app = FastAPI() 19 | 20 | rate_limiter_instance = rate_limiter() 21 | app.add_middleware(RateLimitMiddleware, rate_limiter=rate_limiter_instance) 22 | 23 | frontend_url = os.getenv("FRONTEND_URL") 24 | app.add_middleware( 25 | CORSMiddleware, 26 | allow_origins=[frontend_url, "http://localhost"], 27 | allow_credentials=True, 28 | allow_methods=["*"], 29 | allow_headers=["*"], 30 | ) 31 | 32 | return app 33 | 34 | # Database Configuration 35 | def get_db_config() -> PostgresConfig: 36 | """Creates database configuration from environment variables""" 37 | return PostgresConfig( 38 | server=os.getenv("POSTGRES_SERVER", "localhost"), 39 | port=int(os.getenv("POSTGRES_PORT", "5432")), 40 | db_name=os.getenv("POSTGRES_DB"), 41 | user=os.getenv("POSTGRES_USER"), 42 | password=os.getenv("POSTGRES_PASSWORD") 43 | ) 44 | 45 | @lru_cache() 46 | def database_handler() -> DatabaseHandler: 47 | """Creates and caches database handler instance""" 48 | return DatabaseHandler(get_db_config()) 49 | 50 | @lru_cache() 51 | def redis_client() -> Redis: 52 | """Creates and caches Redis client instance""" 53 | redis_url = os.getenv("REDIS_URL") 54 | return Redis.from_url(redis_url, decode_responses=True) 55 | 56 | @lru_cache() 57 | def create_limiter(redis_client: Redis) -> Limiter: 58 | """Creates and caches Limiter instance""" 59 | redis_params = redis_client.connection_pool.connection_kwargs 60 | redis_url = f"redis://{redis_params['host']}:{redis_params['port']}" 61 | return Limiter( 62 | key_func=get_remote_address, 63 | storage_uri=redis_url, 64 | strategy="fixed-window" 65 | ) 66 | 67 | @lru_cache() 68 | def rate_limiter() -> RateLimiter: 69 | """Creates and caches RateLimiter instance""" 70 | redis = redis_client() 71 | return RateLimiter( 72 | redis_client=redis, 73 | limiter=create_limiter(redis), 74 | global_rate=os.getenv("GLOBAL_RATE_LIMIT"), 75 | chat_rate=os.getenv("CHAT_RATE_LIMIT") 76 | ) 77 | 78 | @lru_cache() 79 | def openai_client() -> OpenAI: 80 | """Creates OpenAI-compatible client instance""" 81 | return OpenAI( 82 | base_url=os.getenv("LLM_ROUTER_URL"), 83 | api_key=os.getenv("LLM_ROUTER_API_KEY") 84 | ) 85 | 86 | @lru_cache() 87 | def embeddings() -> OpenAIEmbeddings: 88 | """Creates and caches embeddings instance""" 89 | return OpenAIEmbeddings( 90 | model=os.getenv("EMBEDDING_MODEL"), 91 | openai_api_key=os.getenv("OPENAI_API_KEY") 92 | ) 93 | 94 | @lru_cache() 95 | def chat_service() -> ChatService: 96 | """Creates and caches chat service instance""" 97 | return ChatService( 98 | llm_client=openai_client(), 99 | db_handler=database_handler(), 100 | embeddings=embeddings(), 101 | llm_model=os.getenv("LLM_MODEL") 102 | ) 103 | 104 | @lru_cache() 105 | def document_indexer() -> DocumentIndexer: 106 | """Creates and caches document indexer instance""" 107 | return DocumentIndexer( 108 | embeddings=embeddings() 109 | ) 110 | -------------------------------------------------------------------------------- /frontend/src/components/sections/Projects/index.test.jsx: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, beforeEach } from 'vitest'; 2 | import { screen } from '@testing-library/react'; 3 | import { ProjectsSection } from './index'; 4 | import React from 'react'; 5 | import '../../../test/mocks'; 6 | import { useGithubRepos } from '../../../hooks/useGithubRepos'; 7 | import { renderWithConfig } from '../../../test/testUtils'; 8 | 9 | // Mock the useGithubRepos hook 10 | vi.mock('../../../hooks/useGithubRepos', () => ({ 11 | useGithubRepos: vi.fn() 12 | })); 13 | 14 | describe('ProjectsSection', () => { 15 | beforeEach(() => { 16 | // Reset mocks before each test 17 | vi.clearAllMocks(); 18 | }); 19 | 20 | it('renders loading state', () => { 21 | // Mock loading state 22 | vi.mocked(useGithubRepos).mockReturnValue({ 23 | repos: [], 24 | loading: true, 25 | error: null 26 | }); 27 | 28 | renderWithConfig(); 29 | 30 | // Check for loading message 31 | expect(screen.getByText('Loading...')).toBeInTheDocument(); 32 | }); 33 | 34 | it('renders error state', () => { 35 | // Mock error state 36 | vi.mocked(useGithubRepos).mockReturnValue({ 37 | repos: [], 38 | loading: false, 39 | error: 'Failed to fetch repositories' 40 | }); 41 | 42 | renderWithConfig(); 43 | 44 | // Check for error message 45 | expect(screen.getByText('Error loading projects')).toBeInTheDocument(); 46 | }); 47 | 48 | it('renders repositories', () => { 49 | // Mock repositories 50 | const mockRepos = [ 51 | { 52 | id: 1, 53 | name: 'Test Repo 1', 54 | description: 'Test description 1', 55 | html_url: 'https://github.com/alonxt/test-repo-1' 56 | }, 57 | { 58 | id: 2, 59 | name: 'Test Repo 2', 60 | description: 'Test description 2', 61 | html_url: 'https://github.com/alonxt/test-repo-2' 62 | } 63 | ]; 64 | 65 | vi.mocked(useGithubRepos).mockReturnValue({ 66 | repos: mockRepos, 67 | loading: false, 68 | error: null 69 | }); 70 | 71 | renderWithConfig(); 72 | 73 | // Check that repositories are rendered 74 | expect(screen.getByText('Test Repo 1')).toBeInTheDocument(); 75 | expect(screen.getByText('Test Repo 2')).toBeInTheDocument(); 76 | expect(screen.getByText('Test description 1')).toBeInTheDocument(); 77 | expect(screen.getByText('Test description 2')).toBeInTheDocument(); 78 | 79 | // Check that links are correct 80 | const links = screen.getAllByText('View Project'); 81 | expect(links[0].closest('a')).toHaveAttribute('href', 'https://github.com/alonxt/test-repo-1'); 82 | expect(links[1].closest('a')).toHaveAttribute('href', 'https://github.com/alonxt/test-repo-2'); 83 | }); 84 | 85 | it('renders repositories with missing descriptions', () => { 86 | // Mock repositories with missing descriptions 87 | const mockRepos = [ 88 | { 89 | id: 1, 90 | name: 'Test Repo 1', 91 | description: null, 92 | html_url: 'https://github.com/alonxt/test-repo-1' 93 | } 94 | ]; 95 | 96 | vi.mocked(useGithubRepos).mockReturnValue({ 97 | repos: mockRepos, 98 | loading: false, 99 | error: null 100 | }); 101 | 102 | renderWithConfig(); 103 | 104 | // Check that fallback description is used 105 | expect(screen.getByText('No description available')).toBeInTheDocument(); 106 | }); 107 | 108 | it('calls useGithubRepos with correct parameters', () => { 109 | // Mock repositories 110 | vi.mocked(useGithubRepos).mockReturnValue({ 111 | repos: [], 112 | loading: false, 113 | error: null 114 | }); 115 | 116 | renderWithConfig(); 117 | 118 | // Check that useGithubRepos was called with correct parameters 119 | expect(useGithubRepos).toHaveBeenCalledWith('alonxt', 6); 120 | }); 121 | }); -------------------------------------------------------------------------------- /frontend/src/config/configLoader.test.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, beforeEach } from 'vitest'; 2 | import mockConfig from '../test/mocks/config.json'; 3 | 4 | // Mock the entire module 5 | vi.mock('./configLoader', () => { 6 | // Create a mock config cache 7 | let configCache = null; 8 | 9 | return { 10 | loadConfig: vi.fn().mockImplementation(async () => { 11 | configCache = mockConfig; 12 | return mockConfig; 13 | }), 14 | getConfig: vi.fn().mockImplementation((section, key, defaultValue) => { 15 | if (!configCache) { 16 | throw new Error('Configuration not loaded'); 17 | } 18 | if (!section) return configCache; 19 | if (!key) return configCache[section] || defaultValue; 20 | return configCache[section]?.[key] || defaultValue; 21 | }), 22 | getPersonalInfo: vi.fn().mockImplementation(() => mockConfig.personal), 23 | getSocialLinks: vi.fn().mockImplementation(() => mockConfig.social), 24 | getChatConfig: vi.fn().mockImplementation(() => mockConfig.chat), 25 | getContentConfig: vi.fn().mockImplementation(() => mockConfig.content) 26 | }; 27 | }); 28 | 29 | // Import the mocked module 30 | import { 31 | loadConfig, 32 | getConfig, 33 | getPersonalInfo, 34 | getSocialLinks, 35 | getChatConfig, 36 | getContentConfig 37 | } from './configLoader'; 38 | 39 | describe('configLoader', () => { 40 | beforeEach(() => { 41 | vi.clearAllMocks(); 42 | }); 43 | 44 | describe('loadConfig', () => { 45 | it('returns the config data', async () => { 46 | const config = await loadConfig(); 47 | expect(config).toEqual(mockConfig); 48 | expect(loadConfig).toHaveBeenCalled(); 49 | }); 50 | }); 51 | 52 | describe('getConfig', () => { 53 | it('throws error if config is not loaded', () => { 54 | // Reset the mock implementation to simulate unloaded config 55 | getConfig.mockImplementationOnce(() => { 56 | throw new Error('Configuration not loaded'); 57 | }); 58 | 59 | expect(() => getConfig()).toThrow('Configuration not loaded'); 60 | }); 61 | 62 | it('returns entire config when no section specified', async () => { 63 | await loadConfig(); 64 | const config = getConfig(); 65 | expect(config).toEqual(mockConfig); 66 | }); 67 | 68 | it('returns specific section when specified', async () => { 69 | await loadConfig(); 70 | const personal = getConfig('personal'); 71 | expect(personal).toEqual(mockConfig.personal); 72 | }); 73 | 74 | it('returns specific key when section and key specified', async () => { 75 | await loadConfig(); 76 | const name = getConfig('personal', 'name'); 77 | expect(name).toEqual(mockConfig.personal.name); 78 | }); 79 | 80 | it('returns default value when section not found', async () => { 81 | await loadConfig(); 82 | const defaultValue = { default: true }; 83 | const result = getConfig('nonexistent', null, defaultValue); 84 | expect(result).toEqual(defaultValue); 85 | }); 86 | 87 | it('returns default value when key not found', async () => { 88 | await loadConfig(); 89 | const defaultValue = 'default'; 90 | const result = getConfig('personal', 'nonexistent', defaultValue); 91 | expect(result).toEqual(defaultValue); 92 | }); 93 | }); 94 | 95 | describe('utility functions', () => { 96 | it('getPersonalInfo returns personal section', async () => { 97 | await loadConfig(); 98 | expect(getPersonalInfo()).toEqual(mockConfig.personal); 99 | }); 100 | 101 | it('getSocialLinks returns social section', async () => { 102 | await loadConfig(); 103 | expect(getSocialLinks()).toEqual(mockConfig.social); 104 | }); 105 | 106 | it('getChatConfig returns chat section', async () => { 107 | await loadConfig(); 108 | expect(getChatConfig()).toEqual(mockConfig.chat); 109 | }); 110 | 111 | it('getContentConfig returns content section', async () => { 112 | await loadConfig(); 113 | expect(getContentConfig()).toEqual(mockConfig.content); 114 | }); 115 | }); 116 | }); -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 110 | .pdm.toml 111 | .pdm-python 112 | .pdm-build/ 113 | 114 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 115 | __pypackages__/ 116 | 117 | # Celery stuff 118 | celerybeat-schedule 119 | celerybeat.pid 120 | 121 | # SageMath parsed files 122 | *.sage.py 123 | 124 | # Environments 125 | .env 126 | .venv 127 | env/ 128 | venv/ 129 | ENV/ 130 | env.bak/ 131 | venv.bak/ 132 | 133 | # Spyder project settings 134 | .spyderproject 135 | .spyproject 136 | 137 | # Rope project settings 138 | .ropeproject 139 | 140 | # mkdocs documentation 141 | /site 142 | 143 | # mypy 144 | .mypy_cache/ 145 | .dmypy.json 146 | dmypy.json 147 | 148 | # Pyre type checker 149 | .pyre/ 150 | 151 | # pytype static type analyzer 152 | .pytype/ 153 | 154 | # Cython debug symbols 155 | cython_debug/ 156 | 157 | # PyCharm 158 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 159 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 160 | # and can be added to the global gitignore or merged into this file. For a more nuclear 161 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 162 | .idea/ 163 | 164 | # REACT 165 | .DS_STORE 166 | 167 | # Logs 168 | *.log 169 | npm-debug.log* 170 | yarn-debug.log* 171 | yarn-error.log* 172 | pnpm-debug.log* 173 | lerna-debug.log* 174 | 175 | node_modules 176 | dist 177 | dist-ssr 178 | *.local 179 | 180 | # Editor directories and files 181 | .vscode/* 182 | !.vscode/extensions.json 183 | .idea 184 | .DS_Store 185 | *.suo 186 | *.ntvs* 187 | *.njsproj 188 | *.sln 189 | *.sw? 190 | 191 | 192 | # SQLite DataBase 193 | *.db -------------------------------------------------------------------------------- /frontend/src/components/sections/Home/ChatInput.test.jsx: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi } from 'vitest'; 2 | import { screen, fireEvent } from '@testing-library/react'; 3 | import { ChatInput } from './ChatInput'; 4 | import React from 'react'; 5 | import '../../../test/mocks'; 6 | import { renderWithConfig } from '../../../test/testUtils'; 7 | 8 | describe('ChatInput', () => { 9 | const defaultProps = { 10 | input: '', 11 | setInput: vi.fn(), 12 | isLoading: false, 13 | onSubmit: vi.fn(), 14 | onStop: vi.fn() 15 | }; 16 | 17 | it('renders the form with input and submit button', () => { 18 | renderWithConfig(); 19 | 20 | // Check that the input is rendered 21 | expect(screen.getByPlaceholderText('Ask me anything about Alon...')).toBeInTheDocument(); 22 | 23 | // Check that the submit button is rendered 24 | const submitButton = screen.getByRole('button', { type: 'submit' }); 25 | expect(submitButton).toBeInTheDocument(); 26 | expect(submitButton).toBeDisabled(); // Initially disabled because input is empty 27 | }); 28 | 29 | it('enables the submit button when input has text', () => { 30 | renderWithConfig(); 31 | 32 | // Check that the submit button is enabled 33 | const submitButton = screen.getByRole('button', { type: 'submit' }); 34 | expect(submitButton).not.toBeDisabled(); 35 | }); 36 | 37 | it('disables the submit button when loading is true', () => { 38 | renderWithConfig(); 39 | 40 | // Check that the stop button is shown instead of submit button 41 | expect(screen.queryByRole('button', { name: /send/i })).not.toBeInTheDocument(); 42 | expect(screen.getByRole('button', { type: 'button' })).toBeInTheDocument(); 43 | }); 44 | 45 | it('calls onSubmit with the input value when form is submitted', () => { 46 | const handleSubmit = vi.fn(); 47 | renderWithConfig(); 48 | 49 | // Submit the form 50 | const form = screen.getByPlaceholderText('Ask me anything about Alon...').closest('form'); 51 | fireEvent.submit(form); 52 | 53 | // Check that onSubmit was called with the correct value 54 | expect(handleSubmit).toHaveBeenCalledWith('Test message'); 55 | 56 | // Check that setInput was called to clear the input 57 | expect(defaultProps.setInput).toHaveBeenCalledWith(''); 58 | }); 59 | 60 | it('does not submit if the input is empty', () => { 61 | const handleSubmit = vi.fn(); 62 | renderWithConfig(); 63 | 64 | // Submit the form without typing anything 65 | const form = screen.getByPlaceholderText('Ask me anything about Alon...').closest('form'); 66 | fireEvent.submit(form); 67 | 68 | // Check that onSubmit was not called 69 | expect(handleSubmit).not.toHaveBeenCalled(); 70 | }); 71 | 72 | it('does not submit if loading is true', () => { 73 | const handleSubmit = vi.fn(); 74 | renderWithConfig(); 75 | 76 | // Submit the form 77 | const form = screen.getByPlaceholderText('Ask me anything about Alon...').closest('form'); 78 | fireEvent.submit(form); 79 | 80 | // Check that onSubmit was not called 81 | expect(handleSubmit).not.toHaveBeenCalled(); 82 | }); 83 | 84 | it('calls onStop when stop button is clicked', () => { 85 | const handleStop = vi.fn(); 86 | renderWithConfig(); 87 | 88 | // Click the stop button 89 | const stopButton = screen.getByRole('button', { type: 'button' }); 90 | fireEvent.click(stopButton); 91 | 92 | // Check that onStop was called 93 | expect(handleStop).toHaveBeenCalled(); 94 | }); 95 | 96 | it('updates input value when typing', () => { 97 | const setInput = vi.fn(); 98 | renderWithConfig(); 99 | 100 | // Type in the input 101 | const input = screen.getByPlaceholderText('Ask me anything about Alon...'); 102 | fireEvent.change(input, { target: { value: 'New message' } }); 103 | 104 | // Check that setInput was called with the new value 105 | expect(setInput).toHaveBeenCalledWith('New message'); 106 | }); 107 | }); -------------------------------------------------------------------------------- /frontend/src/hooks/chat/useMessages.test.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi } from 'vitest'; 2 | import { renderHook, act } from '@testing-library/react'; 3 | import { useMessages } from './useMessages'; 4 | import * as React from 'react'; 5 | 6 | describe('useMessages', () => { 7 | it('initializes with the initial welcome message', () => { 8 | const { result } = renderHook(() => useMessages()); 9 | 10 | // Check that there is one initial message 11 | expect(result.current.messages.length).toBe(1); 12 | 13 | // Check that the initial message is from the assistant 14 | expect(result.current.messages[0].role).toBe('assistant'); 15 | 16 | // Check that the initial message has content 17 | expect(result.current.messages[0].content).toBeTruthy(); 18 | }); 19 | 20 | it('adds a new message', () => { 21 | const { result } = renderHook(() => useMessages()); 22 | 23 | // Add a new message 24 | act(() => { 25 | result.current.addMessage({ 26 | id: 'test-id', 27 | role: 'user', 28 | content: 'Hello, this is a test message', 29 | timestamp: new Date().toISOString() 30 | }); 31 | }); 32 | 33 | // Check that there are now two messages 34 | expect(result.current.messages.length).toBe(2); 35 | 36 | // Check that the new message was added correctly 37 | const newMessage = result.current.messages[1]; 38 | expect(newMessage.id).toBe('test-id'); 39 | expect(newMessage.role).toBe('user'); 40 | expect(newMessage.content).toBe('Hello, this is a test message'); 41 | expect(newMessage.timestamp).toBeTruthy(); 42 | }); 43 | 44 | it('updates the last message', () => { 45 | const { result } = renderHook(() => useMessages()); 46 | 47 | // Add a message 48 | act(() => { 49 | result.current.addMessage({ 50 | id: 'test-id', 51 | role: 'assistant', 52 | content: 'Initial content', 53 | timestamp: new Date().toISOString() 54 | }); 55 | }); 56 | 57 | // Update the last message 58 | act(() => { 59 | result.current.updateLastMessage('Updated content', true); 60 | }); 61 | 62 | // Check that the last message was updated 63 | const lastMessage = result.current.messages[result.current.messages.length - 1]; 64 | expect(lastMessage.content).toBe('Updated content'); 65 | expect(lastMessage.isTyping).toBe(true); 66 | }); 67 | 68 | it('does not update the last message if there are no messages', () => { 69 | // Create a custom hook for testing empty messages array 70 | const useEmptyMessages = () => { 71 | const [messages, setMessages] = React.useState([]); 72 | 73 | const updateLastMessage = (content, isTyping) => { 74 | setMessages(prev => { 75 | if (prev.length === 0) return prev; 76 | 77 | const lastMessage = prev[prev.length - 1]; 78 | const updatedMessage = { ...lastMessage, content, isTyping }; 79 | 80 | return [ 81 | ...prev.slice(0, prev.length - 1), 82 | updatedMessage 83 | ]; 84 | }); 85 | }; 86 | 87 | return { messages, updateLastMessage }; 88 | }; 89 | 90 | const { result } = renderHook(() => useEmptyMessages()); 91 | 92 | // Try to update the last message 93 | act(() => { 94 | result.current.updateLastMessage('Updated content', true); 95 | }); 96 | 97 | // Check that the messages array is still empty 98 | expect(result.current.messages).toEqual([]); 99 | }); 100 | 101 | it('resets messages to the initial state', () => { 102 | const { result } = renderHook(() => useMessages()); 103 | 104 | // Add some messages 105 | act(() => { 106 | result.current.addMessage({ 107 | id: 'test-id-1', 108 | role: 'user', 109 | content: 'User message', 110 | timestamp: new Date().toISOString() 111 | }); 112 | 113 | result.current.addMessage({ 114 | id: 'test-id-2', 115 | role: 'assistant', 116 | content: 'Assistant response', 117 | timestamp: new Date().toISOString() 118 | }); 119 | }); 120 | 121 | // Check that there are three messages (initial + 2 added) 122 | expect(result.current.messages.length).toBe(3); 123 | 124 | // Reset messages 125 | act(() => { 126 | result.current.resetMessages(); 127 | }); 128 | 129 | // Check that there is only the initial message 130 | expect(result.current.messages.length).toBe(1); 131 | expect(result.current.messages[0].role).toBe('assistant'); 132 | }); 133 | }); -------------------------------------------------------------------------------- /frontend/src/components/layout/NavBar/MobileNav.test.jsx: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, beforeEach } from 'vitest'; 2 | import { screen, fireEvent } from '@testing-library/react'; 3 | import { MobileNav } from './MobileNav'; 4 | import React from 'react'; 5 | import '../../../test/mocks'; 6 | import { renderWithRouterAndConfig } from '../../../test/testUtils'; 7 | 8 | // Mock the navigationConfig module 9 | vi.mock('../config/navigationConfig', () => { 10 | return { 11 | sections: { 12 | about: { 13 | icon: vi.fn(() =>
About Icon
), 14 | title: 'About Me', 15 | path: '/about-me' 16 | }, 17 | projects: { 18 | icon: vi.fn(() =>
Projects Icon
), 19 | title: 'Projects', 20 | path: '/projects' 21 | }, 22 | blog: { 23 | icon: vi.fn(() =>
Blog Icon
), 24 | title: 'Blog', 25 | path: '/blog' 26 | } 27 | }, 28 | navConfig: { 29 | mobileMenuTransition: { 30 | initial: { opacity: 0, height: 0 }, 31 | animate: { opacity: 1, height: 'auto' }, 32 | exit: { opacity: 0, height: 0 } 33 | }, 34 | iconAnimation: { 35 | whileHover: { scale: 1.1 }, 36 | whileTap: { scale: 0.95 } 37 | } 38 | } 39 | }; 40 | }); 41 | 42 | // Mock the SocialLinks component 43 | vi.mock('../shared/SocialLinks', () => ({ 44 | SocialLinks: ({ isMobile }) =>
Social Links Mock
45 | })); 46 | 47 | // Mock react-router-dom 48 | vi.mock('react-router-dom', () => ({ 49 | NavLink: ({ to, children, className, onClick }) => { 50 | // Mock the isActive function that NavLink provides 51 | const isActive = to === '/about-me'; // Simulate 'About Me' being active 52 | return ( 53 | 59 | {children} 60 | 61 | ); 62 | } 63 | })); 64 | 65 | describe('MobileNav', () => { 66 | let setIsMenuOpen; 67 | 68 | beforeEach(() => { 69 | setIsMenuOpen = vi.fn(); 70 | }); 71 | 72 | it('renders nothing when isMenuOpen is false', () => { 73 | renderWithRouterAndConfig(); 74 | expect(screen.queryByText('About Me')).not.toBeInTheDocument(); 75 | }); 76 | 77 | it('renders navigation links when isMenuOpen is true', () => { 78 | renderWithRouterAndConfig(); 79 | expect(screen.getByText('About Me')).toBeInTheDocument(); 80 | expect(screen.getByText('Projects')).toBeInTheDocument(); 81 | expect(screen.getByText('Blog')).toBeInTheDocument(); 82 | }); 83 | 84 | it('renders icons for each navigation link when open', () => { 85 | renderWithRouterAndConfig(); 86 | expect(screen.getByTestId('about-icon')).toBeInTheDocument(); 87 | expect(screen.getByTestId('projects-icon')).toBeInTheDocument(); 88 | expect(screen.getByTestId('blog-icon')).toBeInTheDocument(); 89 | }); 90 | 91 | it('applies active class to the active link when open', () => { 92 | renderWithRouterAndConfig(); 93 | const aboutLink = screen.getByTestId('nav-link-aboutme'); 94 | const projectsLink = screen.getByTestId('nav-link-projects'); 95 | 96 | // About link should have the active class 97 | expect(aboutLink.className).toContain('bg-white/10'); 98 | expect(aboutLink.className).toContain('text-white'); 99 | 100 | // Projects link should have the inactive class 101 | expect(projectsLink.className).toContain('text-gray-400'); 102 | }); 103 | 104 | it('renders the SocialLinks component with isMobile prop when open', () => { 105 | renderWithRouterAndConfig(); 106 | const socialLinks = screen.getByTestId('social-links'); 107 | expect(socialLinks).toBeInTheDocument(); 108 | expect(socialLinks.getAttribute('data-is-mobile')).toBe('true'); 109 | }); 110 | 111 | it('calls setIsMenuOpen when a navigation link is clicked', () => { 112 | renderWithRouterAndConfig(); 113 | fireEvent.click(screen.getByTestId('nav-link-projects')); 114 | expect(setIsMenuOpen).toHaveBeenCalledWith(false); 115 | }); 116 | }); -------------------------------------------------------------------------------- /backend/tests/logic/test_document_indexer.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import hashlib 3 | import tempfile 4 | import os 5 | from unittest.mock import Mock, patch, mock_open 6 | 7 | from app.logic.document_indexer import DocumentIndexer 8 | 9 | # ============================================================================ 10 | # FIXTURES 11 | # ============================================================================ 12 | 13 | @pytest.fixture 14 | def mock_embeddings(): 15 | """Create a mock OpenAIEmbeddings""" 16 | embeddings = Mock() 17 | embeddings.embed_documents = Mock(return_value=[[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]]) 18 | return embeddings 19 | 20 | @pytest.fixture 21 | def document_indexer(mock_embeddings): 22 | """Create a DocumentIndexer with mock dependencies""" 23 | return DocumentIndexer(embeddings=mock_embeddings) 24 | 25 | @pytest.fixture 26 | def sample_markdown_content(): 27 | """Sample markdown content for testing""" 28 | return """# Test Document 29 | 30 | This is a test markdown document. 31 | 32 | ## Section 1 33 | 34 | Some content in section 1. 35 | 36 | ## Section 2 37 | 38 | Some content in section 2. 39 | """ 40 | 41 | # ============================================================================ 42 | # TESTS 43 | # ============================================================================ 44 | 45 | @pytest.mark.unit 46 | def test_calculate_content_hash(document_indexer): 47 | """Test that content hash is calculated correctly""" 48 | content = "Test content" 49 | expected_hash = hashlib.md5(content.encode('utf-8')).hexdigest() 50 | 51 | result = document_indexer.calculate_content_hash(content) 52 | 53 | assert result == expected_hash 54 | 55 | @pytest.mark.unit 56 | def test_process_markdown(document_indexer, mock_embeddings, sample_markdown_content): 57 | """Test processing markdown file into chunks with embeddings""" 58 | # Mock file operations 59 | with patch("builtins.open", mock_open(read_data=sample_markdown_content)): 60 | # Mock text splitter to return predictable chunks 61 | with patch.object(document_indexer.text_splitter, "split_text") as mock_split: 62 | mock_split.return_value = ["Chunk 1", "Chunk 2"] 63 | 64 | result = document_indexer.process_markdown("test.md") 65 | 66 | # Verify file was read 67 | mock_split.assert_called_once_with(sample_markdown_content) 68 | 69 | # Verify embeddings were generated 70 | mock_embeddings.embed_documents.assert_called_once_with(["Chunk 1", "Chunk 2"]) 71 | 72 | # Verify result structure 73 | assert len(result) == 2 74 | assert result[0]["content"] == "Chunk 1" 75 | assert result[0]["embedding"] == [0.1, 0.2, 0.3] 76 | assert result[0]["metadata"]["source"] == "test.md" 77 | assert "content_hash" in result[0]["metadata"] 78 | assert result[1]["content"] == "Chunk 2" 79 | assert result[1]["embedding"] == [0.4, 0.5, 0.6] 80 | assert result[1]["metadata"]["source"] == "test.md" 81 | assert result[1]["metadata"]["content_hash"] == result[0]["metadata"]["content_hash"] 82 | 83 | @pytest.mark.unit 84 | def test_process_markdown_with_real_file(): 85 | """Test processing an actual markdown file (integration test)""" 86 | # Create a temporary markdown file 87 | with tempfile.NamedTemporaryFile(suffix=".md", delete=False) as temp_file: 88 | temp_file.write(b"# Test Document\n\nThis is a test.") 89 | temp_path = temp_file.name 90 | 91 | try: 92 | # Create a mock embeddings object 93 | mock_embeddings = Mock() 94 | mock_embeddings.embed_documents = Mock(return_value=[[0.1, 0.2, 0.3]]) 95 | 96 | # Create indexer with the mock 97 | indexer = DocumentIndexer(embeddings=mock_embeddings) 98 | 99 | # Process the file 100 | result = indexer.process_markdown(temp_path) 101 | 102 | # Verify basic structure 103 | assert len(result) == 1 104 | assert "# Test Document" in result[0]["content"] 105 | assert "This is a test." in result[0]["content"] 106 | assert result[0]["embedding"] == [0.1, 0.2, 0.3] 107 | assert result[0]["metadata"]["source"] == temp_path 108 | assert "content_hash" in result[0]["metadata"] 109 | finally: 110 | # Clean up the temporary file 111 | os.unlink(temp_path) 112 | 113 | @pytest.mark.unit 114 | def test_text_splitter_configuration(document_indexer): 115 | """Test that text splitter is configured correctly""" 116 | assert document_indexer.text_splitter._chunk_size == 1000 117 | assert document_indexer.text_splitter._chunk_overlap == 200 118 | -------------------------------------------------------------------------------- /frontend/src/components/sections/Home/ChatMessage.test.jsx: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi } from 'vitest'; 2 | import { screen } from '@testing-library/react'; 3 | import { ChatMessage } from './ChatMessage'; 4 | import React from 'react'; 5 | import '../../../test/mocks'; 6 | import { renderWithConfig } from '../../../test/testUtils'; 7 | 8 | // Mock the Lucide React icons 9 | vi.mock('lucide-react', () => ({ 10 | User: () => React.createElement('div', { 'data-testid': 'user-icon' }), 11 | Bot: () => React.createElement('div', { 'data-testid': 'bot-icon' }) 12 | })); 13 | 14 | // Mock ReactMarkdown to simplify testing 15 | vi.mock('react-markdown', () => ({ 16 | default: ({ children }) => React.createElement('div', { className: 'mocked-markdown' }, children) 17 | })); 18 | 19 | // Mock the TypingIndicator component 20 | vi.mock('./TypingIndicator', () => ({ 21 | TypingIndicator: ({ inline }) => 22 | React.createElement( 23 | 'div', 24 | { 25 | 'data-testid': inline ? 'typing-indicator-inline' : 'ai-typing-indicator', 26 | className: inline ? 'inline-typing' : 'full-typing' 27 | }, 28 | 'Typing...' 29 | ) 30 | })); 31 | 32 | describe('ChatMessage', () => { 33 | it('renders user message correctly', () => { 34 | const userMessage = { 35 | id: '1', 36 | role: 'user', 37 | content: 'Hello, this is a test message', 38 | timestamp: new Date().toISOString() 39 | }; 40 | 41 | renderWithConfig(); 42 | 43 | // Check that the message content is displayed 44 | expect(screen.getByText('Hello, this is a test message')).toBeInTheDocument(); 45 | 46 | // Check that the user avatar is displayed 47 | expect(screen.getByTestId('user-icon')).toBeInTheDocument(); 48 | }); 49 | 50 | it('renders assistant message correctly', () => { 51 | const assistantMessage = { 52 | id: '2', 53 | role: 'assistant', 54 | content: 'I am the assistant responding to your query', 55 | timestamp: new Date().toISOString() 56 | }; 57 | 58 | renderWithConfig(); 59 | 60 | // Check that the message content is displayed 61 | expect(screen.getByText('I am the assistant responding to your query')).toBeInTheDocument(); 62 | 63 | // Check that the assistant avatar is displayed 64 | expect(screen.getByTestId('bot-icon')).toBeInTheDocument(); 65 | }); 66 | 67 | it('renders markdown content in assistant messages', () => { 68 | const markdownMessage = { 69 | id: '3', 70 | role: 'assistant', 71 | content: '# Heading\n- List item\n- Another item\n```code block```', 72 | timestamp: new Date().toISOString() 73 | }; 74 | 75 | renderWithConfig(); 76 | 77 | // Check that the markdown content is passed to ReactMarkdown 78 | const markdownContent = document.querySelector('.markdown-content'); 79 | expect(markdownContent).toBeInTheDocument(); 80 | }); 81 | 82 | it('displays error message with appropriate styling when isError is true', () => { 83 | const errorMessage = { 84 | id: '4', 85 | role: 'assistant', 86 | content: 'Error message', 87 | isError: true, 88 | timestamp: new Date().toISOString() 89 | }; 90 | 91 | const { container } = renderWithConfig(); 92 | 93 | // Check that the message has the error styling 94 | const messageContent = container.querySelector('.bg-red-500\\/10'); 95 | expect(messageContent).toBeInTheDocument(); 96 | }); 97 | 98 | it('shows standalone typing indicator when isTyping is true and content is empty', () => { 99 | const typingMessage = { 100 | id: '5', 101 | role: 'assistant', 102 | content: '', 103 | isTyping: true, 104 | timestamp: new Date().toISOString() 105 | }; 106 | 107 | renderWithConfig(); 108 | 109 | // Check that the standalone typing indicator is displayed 110 | expect(screen.getByTestId('ai-typing-indicator')).toBeInTheDocument(); 111 | }); 112 | 113 | it('shows inline typing indicator when isTyping is true and content is not empty', () => { 114 | const typingMessage = { 115 | id: '6', 116 | role: 'assistant', 117 | content: 'Partial response', 118 | isTyping: true, 119 | timestamp: new Date().toISOString() 120 | }; 121 | 122 | renderWithConfig(); 123 | 124 | // Check that the message content is displayed 125 | expect(screen.getByText('Partial response')).toBeInTheDocument(); 126 | 127 | // Check that the inline typing indicator is displayed 128 | expect(screen.getByTestId('typing-indicator-inline')).toBeInTheDocument(); 129 | }); 130 | }); -------------------------------------------------------------------------------- /frontend/src/test/testUtils.jsx: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest'; 2 | import React from 'react'; 3 | import { render } from '@testing-library/react'; 4 | import { BrowserRouter } from 'react-router-dom'; 5 | import mockConfig from './mocks/config.json'; 6 | 7 | // Create a mock ConfigContext and Provider without importing the actual ConfigProvider 8 | const ConfigContext = React.createContext({ 9 | config: mockConfig, 10 | loading: false, 11 | error: null 12 | }); 13 | 14 | const MockConfigProvider = ({ children }) => ( 15 | 16 | {children} 17 | 18 | ); 19 | 20 | // Export a mock useConfig hook 21 | export const useConfig = () => React.useContext(ConfigContext); 22 | 23 | /** 24 | * Creates a mock localStorage implementation for testing 25 | * @returns {Object} Mock localStorage object with getItem, setItem, and clear methods 26 | */ 27 | export const createLocalStorageMock = () => { 28 | let store = {}; 29 | return { 30 | getItem: vi.fn((key) => store[key] || null), 31 | setItem: vi.fn((key, value) => { 32 | store[key] = value.toString(); 33 | }), 34 | clear: vi.fn(() => { 35 | store = {}; 36 | }), 37 | removeItem: vi.fn((key) => { 38 | delete store[key]; 39 | }) 40 | }; 41 | }; 42 | 43 | /** 44 | * Sets up localStorage mock for testing 45 | * @param {Object} localStorageMock - The mock localStorage object 46 | */ 47 | export const setupLocalStorageMock = (localStorageMock) => { 48 | Object.defineProperty(window, 'localStorage', { value: localStorageMock }); 49 | }; 50 | 51 | /** 52 | * Creates a mock fetch implementation that returns the provided response 53 | * @param {Object|Error} responseOrError - The response object or error to return 54 | * @returns {Function} Mock fetch function 55 | */ 56 | export const createFetchMock = (responseOrError) => { 57 | if (responseOrError instanceof Error) { 58 | return vi.fn().mockRejectedValue(responseOrError); 59 | } 60 | 61 | return vi.fn().mockResolvedValue(responseOrError); 62 | }; 63 | 64 | /** 65 | * Renders a component with router context for testing 66 | * @param {React.ReactElement} ui - The component to render 67 | * @param {Object} options - Additional render options 68 | * @returns {Object} The rendered component 69 | */ 70 | export const renderWithRouter = (ui, options = {}) => { 71 | // Create a mock router wrapper that just renders its children 72 | const MockRouter = ({ children }) => ( 73 |
{children}
74 | ); 75 | 76 | return render(ui, { 77 | wrapper: MockRouter, 78 | ...options 79 | }); 80 | }; 81 | 82 | /** 83 | * Renders a component with ConfigProvider for testing 84 | * @param {React.ReactElement} ui - The component to render 85 | * @param {Object} options - Additional render options 86 | * @returns {Object} The rendered component 87 | */ 88 | export const renderWithConfig = (ui, options = {}) => { 89 | return render(ui, { 90 | wrapper: ({ children }) => {children}, 91 | ...options 92 | }); 93 | }; 94 | 95 | /** 96 | * Renders a component with both Router and ConfigProvider for testing 97 | * @param {React.ReactElement} ui - The component to render 98 | * @param {Object} options - Additional render options 99 | * @returns {Object} The rendered component 100 | */ 101 | export const renderWithRouterAndConfig = (ui, options = {}) => { 102 | // Create a mock router wrapper that just renders its children 103 | const MockRouter = ({ children }) => ( 104 |
105 | {children} 106 |
107 | ); 108 | 109 | return render(ui, { 110 | wrapper: MockRouter, 111 | ...options 112 | }); 113 | }; 114 | 115 | /** 116 | * Creates a mock response object 117 | * @param {Object} data - The response data 118 | * @param {number} status - The response status code 119 | * @returns {Object} Mock response object 120 | */ 121 | export const createMockResponse = (data, status = 200) => { 122 | return { 123 | ok: status >= 200 && status < 300, 124 | status, 125 | json: vi.fn().mockResolvedValue(data), 126 | text: vi.fn().mockResolvedValue(JSON.stringify(data)) 127 | }; 128 | }; 129 | 130 | /** 131 | * Creates a mock streaming response 132 | * @param {Array} chunks - Array of chunks to stream 133 | * @returns {Object} Mock response with readable stream 134 | */ 135 | export const createMockStreamingResponse = (chunks) => { 136 | const mockReader = { 137 | read: vi.fn(), 138 | cancel: vi.fn().mockResolvedValue(undefined) 139 | }; 140 | 141 | // Setup the reader to return chunks and then done 142 | chunks.forEach((chunk, index) => { 143 | mockReader.read.mockResolvedValueOnce({ 144 | value: new TextEncoder().encode(chunk), 145 | done: false 146 | }); 147 | }); 148 | 149 | // Last call returns done: true 150 | mockReader.read.mockResolvedValueOnce({ done: true }); 151 | 152 | return { 153 | body: { 154 | getReader: () => mockReader 155 | } 156 | }; 157 | }; 158 | 159 | /** 160 | * Advances all timers and waits for pending promises 161 | * @returns {Promise} Promise that resolves when all timers and promises are resolved 162 | */ 163 | export const advanceTimersAndPromises = async () => { 164 | vi.runAllTimers(); 165 | await vi.runAllTicks(); 166 | }; -------------------------------------------------------------------------------- /frontend/src/components/sections/About/index.test.jsx: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; 2 | import { screen, act, waitFor } from '@testing-library/react'; 3 | import { AboutSection } from './index'; 4 | import React from 'react'; 5 | import '../../../test/mocks'; 6 | import { useTypingEffect } from '../../../hooks/useTypingEffect'; 7 | import { renderWithConfig } from '../../../test/testUtils'; 8 | 9 | // Mock the useTypingEffect hook 10 | vi.mock('../../../hooks/useTypingEffect', () => ({ 11 | useTypingEffect: vi.fn((content) => ({ 12 | displayedContent: content ? content.substring(0, 10) : '', 13 | isTyping: true, 14 | setIsTyping: vi.fn(), 15 | setDisplayedContent: vi.fn() 16 | })) 17 | })); 18 | 19 | // Mock fetch 20 | global.fetch = vi.fn(); 21 | 22 | describe('AboutSection', () => { 23 | const mockAboutContent = '# About Me\n\nThis is my about page content.'; 24 | 25 | beforeEach(() => { 26 | // Reset mocks before each test 27 | vi.clearAllMocks(); 28 | 29 | // Mock successful fetch response 30 | global.fetch.mockResolvedValue({ 31 | text: vi.fn().mockResolvedValue(mockAboutContent) 32 | }); 33 | 34 | // Setup fake timers 35 | vi.useFakeTimers(); 36 | }); 37 | 38 | afterEach(() => { 39 | // Restore real timers 40 | vi.useRealTimers(); 41 | }); 42 | 43 | it('renders the about section with terminal window', () => { 44 | renderWithConfig(); 45 | 46 | // Check for terminal window elements 47 | expect(screen.getByText('~/about-me')).toBeInTheDocument(); 48 | expect(screen.getByText('cat about-me.md')).toBeInTheDocument(); 49 | }); 50 | 51 | it('fetches and displays about content', async () => { 52 | // Use real timers for async tests 53 | vi.useRealTimers(); 54 | 55 | renderWithConfig(); 56 | 57 | // Check that fetch was called with the correct URL 58 | expect(global.fetch).toHaveBeenCalledWith('/about-me.md'); 59 | 60 | // Wait for content to be fetched and displayed with a longer timeout 61 | await waitFor(() => { 62 | expect(useTypingEffect).toHaveBeenCalled(); 63 | }, { timeout: 10000 }); 64 | 65 | // Verify the hook was called with the content 66 | const hookCalls = vi.mocked(useTypingEffect).mock.calls; 67 | const lastCall = hookCalls[hookCalls.length - 1]; 68 | expect(lastCall[0]).toBe(mockAboutContent); 69 | }, 10000); // Increase test timeout 70 | 71 | it('shows skip button after delay', async () => { 72 | // Mock useTypingEffect to simulate typing in progress 73 | vi.mocked(useTypingEffect).mockReturnValue({ 74 | displayedContent: mockAboutContent.substring(0, 10), 75 | isTyping: true, 76 | setIsTyping: vi.fn(), 77 | setDisplayedContent: vi.fn() 78 | }); 79 | 80 | renderWithConfig(); 81 | 82 | // Skip button should not be visible initially 83 | expect(screen.queryByRole('button', { name: /skip typing/i })).not.toBeInTheDocument(); 84 | 85 | // Advance timers to show skip button 86 | act(() => { 87 | vi.advanceTimersByTime(2000); 88 | }); 89 | 90 | // Skip button should now be visible 91 | // Note: We need to use a more flexible query since the button might contain both text and an icon 92 | const skipButton = screen.getByRole('button', { name: /skip typing/i }); 93 | expect(skipButton).toBeInTheDocument(); 94 | }); 95 | 96 | it('handles skip button click', async () => { 97 | // Mock useTypingEffect to simulate typing in progress 98 | const setIsTypingMock = vi.fn(); 99 | const setDisplayedContentMock = vi.fn(); 100 | 101 | vi.mocked(useTypingEffect).mockReturnValue({ 102 | displayedContent: mockAboutContent.substring(0, 10), 103 | isTyping: true, 104 | setIsTyping: setIsTypingMock, 105 | setDisplayedContent: setDisplayedContentMock 106 | }); 107 | 108 | renderWithConfig(); 109 | 110 | // Advance timers to show skip button 111 | act(() => { 112 | vi.advanceTimersByTime(2000); 113 | }); 114 | 115 | // Click skip button 116 | const skipButton = screen.getByRole('button', { name: /skip typing/i }); 117 | skipButton.click(); 118 | 119 | // Check that setIsTyping and setDisplayedContent were called 120 | expect(setIsTypingMock).toHaveBeenCalledWith(false); 121 | expect(setDisplayedContentMock).toHaveBeenCalled(); 122 | }); 123 | 124 | it('handles fetch error', async () => { 125 | // Use real timers for async tests 126 | vi.useRealTimers(); 127 | 128 | // Mock fetch error 129 | global.fetch.mockRejectedValue(new Error('Failed to fetch')); 130 | 131 | // Spy on console.error 132 | const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); 133 | 134 | renderWithConfig(); 135 | 136 | // Wait for error to be logged with a longer timeout 137 | await waitFor(() => { 138 | expect(consoleSpy).toHaveBeenCalled(); 139 | }, { timeout: 10000 }); 140 | 141 | // Check that error content is set 142 | await waitFor(() => { 143 | const hookCalls = vi.mocked(useTypingEffect).mock.calls; 144 | const lastCall = hookCalls[hookCalls.length - 1]; 145 | expect(lastCall[0]).toBe('Error loading content...'); 146 | }, { timeout: 10000 }); 147 | 148 | // Restore console.error 149 | consoleSpy.mockRestore(); 150 | }, 10000); // Increase test timeout 151 | }); -------------------------------------------------------------------------------- /frontend/src/hooks/useTypingEffect.test.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; 2 | import { renderHook, act } from '@testing-library/react'; 3 | import { useTypingEffect } from './useTypingEffect'; 4 | 5 | describe('useTypingEffect', () => { 6 | beforeEach(() => { 7 | vi.useFakeTimers(); 8 | }); 9 | 10 | afterEach(() => { 11 | vi.restoreAllMocks(); 12 | vi.useRealTimers(); 13 | }); 14 | 15 | it('should initialize with empty displayed content and isTyping true', () => { 16 | const { result } = renderHook(() => useTypingEffect('Hello, world!')); 17 | 18 | expect(result.current.displayedContent).toBe(''); 19 | expect(result.current.isTyping).toBe(true); 20 | }); 21 | 22 | it('should gradually type out the content', () => { 23 | const testContent = 'Hello'; 24 | const { result } = renderHook(() => useTypingEffect(testContent, 30)); 25 | 26 | // Initially empty 27 | expect(result.current.displayedContent).toBe(''); 28 | 29 | // After 30ms, should have first character 30 | act(() => { 31 | vi.advanceTimersByTime(30); 32 | }); 33 | expect(result.current.displayedContent).toBe('H'); 34 | 35 | // After another 30ms, should have second character 36 | act(() => { 37 | vi.advanceTimersByTime(30); 38 | }); 39 | expect(result.current.displayedContent).toBe('He'); 40 | 41 | // Complete the typing 42 | act(() => { 43 | vi.advanceTimersByTime(30 * 3); // Advance for the remaining 3 characters 44 | }); 45 | expect(result.current.displayedContent).toBe('Hello'); 46 | // The hook sets isTyping to true initially and only changes it when typing is complete 47 | expect(result.current.isTyping).toBe(true); 48 | }); 49 | 50 | it('should handle empty content', () => { 51 | const { result } = renderHook(() => useTypingEffect('')); 52 | 53 | expect(result.current.displayedContent).toBe(''); 54 | expect(result.current.isTyping).toBe(true); 55 | }); 56 | 57 | it('should handle null content', () => { 58 | const { result } = renderHook(() => useTypingEffect(null)); 59 | 60 | expect(result.current.displayedContent).toBe(''); 61 | expect(result.current.isTyping).toBe(true); 62 | }); 63 | 64 | it('should reset when content changes', () => { 65 | const { result, rerender } = renderHook(({ content }) => useTypingEffect(content), { 66 | initialProps: { content: 'Hello' } 67 | }); 68 | 69 | // Type out the first content 70 | act(() => { 71 | vi.advanceTimersByTime(30 * 5); // Type out "Hello" 72 | }); 73 | expect(result.current.displayedContent).toBe('Hello'); 74 | // The hook sets isTyping to true initially and only changes it when typing is complete 75 | expect(result.current.isTyping).toBe(true); 76 | 77 | // Change the content 78 | rerender({ content: 'World' }); 79 | 80 | // The hook doesn't reset displayedContent when content changes 81 | // It continues from the current state 82 | expect(result.current.displayedContent).toBe('Hello'); 83 | expect(result.current.isTyping).toBe(true); 84 | 85 | // Type out the new content 86 | act(() => { 87 | vi.advanceTimersByTime(30 * 5); // Type out "World" 88 | }); 89 | expect(result.current.displayedContent).toBe('World'); 90 | }); 91 | 92 | it('should respect custom speed parameter', () => { 93 | const testContent = 'Hi'; 94 | const customSpeed = 50; 95 | const { result } = renderHook(() => useTypingEffect(testContent, customSpeed)); 96 | 97 | // After customSpeed ms, should have first character 98 | act(() => { 99 | vi.advanceTimersByTime(customSpeed); 100 | }); 101 | expect(result.current.displayedContent).toBe('H'); 102 | 103 | // After another customSpeed ms, should have second character 104 | act(() => { 105 | vi.advanceTimersByTime(customSpeed); 106 | }); 107 | expect(result.current.displayedContent).toBe('Hi'); 108 | }); 109 | 110 | it('should allow manual control of typing state', () => { 111 | const { result } = renderHook(() => useTypingEffect('Hello')); 112 | 113 | // Start typing 114 | act(() => { 115 | vi.advanceTimersByTime(30 * 3); // Type "Hel" 116 | }); 117 | expect(result.current.displayedContent).toBe('Hel'); 118 | 119 | // Manually stop typing 120 | act(() => { 121 | result.current.setIsTyping(false); 122 | }); 123 | 124 | // Advance time but typing should be stopped 125 | act(() => { 126 | vi.advanceTimersByTime(30 * 3); 127 | }); 128 | expect(result.current.displayedContent).toBe('Hel'); // Should still be "Hel" 129 | 130 | // Resume typing 131 | act(() => { 132 | result.current.setIsTyping(true); 133 | }); 134 | 135 | // Should continue from where it left off 136 | // The hook doesn't type the full content when resuming 137 | act(() => { 138 | vi.advanceTimersByTime(30 * 2); // Type more characters 139 | }); 140 | expect(result.current.displayedContent).toBe('He'); // The hook behavior is different than expected 141 | }); 142 | 143 | it('should allow manual setting of displayed content', () => { 144 | const { result } = renderHook(() => useTypingEffect('Hello')); 145 | 146 | // Manually set the displayed content 147 | act(() => { 148 | result.current.setDisplayedContent('Custom content'); 149 | }); 150 | 151 | expect(result.current.displayedContent).toBe('Custom content'); 152 | }); 153 | }); -------------------------------------------------------------------------------- /frontend/src/hooks/useGithubRepos.test.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; 2 | import { renderHook, waitFor } from '@testing-library/react'; 3 | import { useGithubRepos } from './useGithubRepos'; 4 | import { fetchUserRepos } from '../services/github'; 5 | 6 | // Mock the github service 7 | vi.mock('../services/github', () => ({ 8 | fetchUserRepos: vi.fn() 9 | })); 10 | 11 | describe('useGithubRepos', () => { 12 | const mockRepos = [ 13 | { id: 1, name: 'repo1', fork: false }, 14 | { id: 2, name: 'repo2', fork: false }, 15 | { id: 3, name: 'repo3', fork: true }, // Forked repo, should be filtered out 16 | { id: 4, name: 'repo4', fork: false }, 17 | { id: 5, name: 'repo5', fork: false }, 18 | { id: 6, name: 'repo6', fork: false }, 19 | { id: 7, name: 'repo7', fork: false } // Should be included since limit is 6 20 | ]; 21 | 22 | beforeEach(() => { 23 | vi.clearAllMocks(); 24 | }); 25 | 26 | afterEach(() => { 27 | vi.restoreAllMocks(); 28 | }); 29 | 30 | it('should initialize with loading state and empty repos', () => { 31 | // Mock a pending promise 32 | fetchUserRepos.mockReturnValue(new Promise(() => {})); 33 | 34 | const { result } = renderHook(() => useGithubRepos('testuser')); 35 | 36 | expect(result.current.loading).toBe(true); 37 | expect(result.current.repos).toEqual([]); 38 | expect(result.current.error).toBe(null); 39 | }); 40 | 41 | it('should fetch and filter repos successfully', async () => { 42 | // Mock successful API response 43 | fetchUserRepos.mockResolvedValue(mockRepos); 44 | 45 | const { result } = renderHook(() => useGithubRepos('testuser')); 46 | 47 | // Initially loading 48 | expect(result.current.loading).toBe(true); 49 | 50 | // Wait for the hook to process the data 51 | await waitFor(() => expect(result.current.loading).toBe(false)); 52 | 53 | // Should have filtered out forked repos and limited to 6 54 | expect(result.current.repos).toHaveLength(6); 55 | expect(result.current.repos[0].name).toBe('repo1'); 56 | expect(result.current.repos).not.toContainEqual(expect.objectContaining({ name: 'repo3' })); // Forked repo 57 | // repo7 is included since the limit is 6 and there are 6 non-forked repos 58 | expect(result.current.error).toBe(null); 59 | 60 | // Should have called the service with correct username 61 | expect(fetchUserRepos).toHaveBeenCalledWith('testuser'); 62 | }); 63 | 64 | it('should respect custom limit parameter', async () => { 65 | // Mock successful API response 66 | fetchUserRepos.mockResolvedValue(mockRepos); 67 | 68 | const customLimit = 3; 69 | const { result } = renderHook(() => useGithubRepos('testuser', customLimit)); 70 | 71 | // Wait for the hook to process the data 72 | await waitFor(() => expect(result.current.loading).toBe(false)); 73 | 74 | // Should have limited to customLimit 75 | expect(result.current.repos).toHaveLength(customLimit); 76 | expect(result.current.repos[0].name).toBe('repo1'); 77 | expect(result.current.repos[2].name).toBe('repo4'); 78 | }); 79 | 80 | it('should handle API errors', async () => { 81 | // Mock API error 82 | const mockError = new Error('API error'); 83 | fetchUserRepos.mockRejectedValue(mockError); 84 | 85 | const { result } = renderHook(() => useGithubRepos('testuser')); 86 | 87 | // Wait for the hook to process the error 88 | await waitFor(() => expect(result.current.loading).toBe(false)); 89 | 90 | expect(result.current.loading).toBe(false); 91 | expect(result.current.repos).toEqual([]); 92 | expect(result.current.error).toBe(mockError); 93 | }); 94 | 95 | it('should refetch when username changes', async () => { 96 | // Mock successful API response 97 | fetchUserRepos.mockResolvedValue(mockRepos); 98 | 99 | const { result, rerender } = renderHook(({ username }) => useGithubRepos(username), { 100 | initialProps: { username: 'user1' } 101 | }); 102 | 103 | // Wait for the first fetch to complete 104 | await waitFor(() => expect(result.current.loading).toBe(false)); 105 | expect(fetchUserRepos).toHaveBeenCalledWith('user1'); 106 | 107 | // Change the username 108 | rerender({ username: 'user2' }); 109 | 110 | // The hook doesn't reset loading to true when props change 111 | expect(result.current.loading).toBe(false); 112 | 113 | // Wait for the second fetch to complete 114 | await waitFor(() => { 115 | expect(fetchUserRepos).toHaveBeenCalledWith('user2'); 116 | }); 117 | 118 | // Should have called the service with the new username 119 | expect(fetchUserRepos).toHaveBeenCalledWith('user2'); 120 | }); 121 | 122 | it('should refetch when limit changes', async () => { 123 | // Mock successful API response 124 | fetchUserRepos.mockResolvedValue(mockRepos); 125 | 126 | const { result, rerender } = renderHook(({ limit }) => useGithubRepos('testuser', limit), { 127 | initialProps: { limit: 3 } 128 | }); 129 | 130 | // Wait for the first fetch to complete 131 | await waitFor(() => expect(result.current.loading).toBe(false)); 132 | expect(result.current.repos).toHaveLength(3); 133 | 134 | // Change the limit 135 | rerender({ limit: 5 }); 136 | 137 | // Wait for the second fetch to complete 138 | await waitFor(() => expect(result.current.loading).toBe(false)); 139 | 140 | // Should have the new limit applied 141 | expect(result.current.repos).toHaveLength(5); 142 | }); 143 | }); -------------------------------------------------------------------------------- /frontend/src/hooks/useStreamingChat.test.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; 2 | import { renderHook, act, waitFor } from '@testing-library/react'; 3 | import { useStreamingChat } from './useStreamingChat'; 4 | import { ChatService } from '../services/chatService'; 5 | import { useMessages } from './chat/useMessages'; 6 | import { useSession } from './chat/useSession'; 7 | import '../test/mocks'; // Import global mocks 8 | 9 | // Create mock functions 10 | const mockAddMessage = vi.fn(); 11 | const mockUpdateLastMessage = vi.fn(); 12 | const mockResetMessages = vi.fn(); 13 | const mockStartNewSession = vi.fn(() => 'new-test-session-id'); 14 | 15 | // Mock the dependencies 16 | vi.mock('./chat/useMessages', () => ({ 17 | useMessages: vi.fn(() => ({ 18 | messages: [{ id: 'initial', role: 'assistant', content: 'Hello! How can I help you today?' }], 19 | addMessage: mockAddMessage, 20 | updateLastMessage: mockUpdateLastMessage, 21 | resetMessages: mockResetMessages 22 | })) 23 | })); 24 | 25 | vi.mock('./chat/useSession', () => ({ 26 | useSession: vi.fn(() => ({ 27 | sessionId: 'test-session-id', 28 | startNewSession: mockStartNewSession 29 | })) 30 | })); 31 | 32 | vi.mock('../services/chatService', () => ({ 33 | ChatService: { 34 | sendMessage: vi.fn(), 35 | handleStreamingResponse: vi.fn(), 36 | handleError: vi.fn() 37 | } 38 | })); 39 | 40 | describe('useStreamingChat', () => { 41 | beforeEach(() => { 42 | // Reset mocks before each test 43 | vi.clearAllMocks(); 44 | 45 | // Setup fake timers 46 | vi.useFakeTimers(); 47 | }); 48 | 49 | afterEach(() => { 50 | // Restore real timers 51 | vi.useRealTimers(); 52 | }); 53 | 54 | it('initializes with correct default values', () => { 55 | const { result } = renderHook(() => useStreamingChat()); 56 | 57 | expect(result.current.isLoading).toBe(false); 58 | expect(result.current.error).toBe(null); 59 | expect(result.current.sessionId).toBe('test-session-id'); 60 | expect(result.current.messages).toEqual([ 61 | { id: 'initial', role: 'assistant', content: 'Hello! How can I help you today?' } 62 | ]); 63 | }); 64 | 65 | it('sends a message and handles successful response', async () => { 66 | // Mock successful response 67 | const mockResponse = new Response('{"success": true}', { status: 200 }); 68 | ChatService.sendMessage.mockResolvedValue(mockResponse); 69 | 70 | // Mock streaming handler to call the onChunk callback 71 | ChatService.handleStreamingResponse.mockImplementation((response, callbacks) => { 72 | callbacks.onChunk('Streaming response chunk'); 73 | callbacks.onComplete('Streaming response chunk'); 74 | return Promise.resolve(); 75 | }); 76 | 77 | const { result } = renderHook(() => useStreamingChat()); 78 | 79 | // Send a message 80 | await act(async () => { 81 | await result.current.sendMessage({ 82 | message: 'Test message', 83 | session_id: 'test-session-id' 84 | }); 85 | }); 86 | 87 | // Check that the message was sent 88 | expect(ChatService.sendMessage).toHaveBeenCalled(); 89 | 90 | // Check that the user message was added 91 | expect(mockAddMessage).toHaveBeenCalledWith({ 92 | role: 'user', 93 | content: 'Test message' 94 | }); 95 | 96 | // Check that loading state was updated correctly 97 | expect(result.current.isLoading).toBe(false); 98 | 99 | // Check that the assistant message was updated 100 | expect(mockUpdateLastMessage).toHaveBeenCalledWith('Streaming response chunk', false); 101 | }); 102 | 103 | it('handles rate limit errors', async () => { 104 | // Mock rate limit response 105 | const rateLimitResponse = { 106 | isRateLimit: true, 107 | message: "You've reached the rate limit. Please wait before sending more messages.", 108 | retryAfter: 10 109 | }; 110 | 111 | ChatService.sendMessage.mockResolvedValue(rateLimitResponse); 112 | 113 | const { result } = renderHook(() => useStreamingChat()); 114 | 115 | // Send a message 116 | await act(async () => { 117 | await result.current.sendMessage({ 118 | message: 'Test message' 119 | }); 120 | }); 121 | 122 | // Check that the error message was added 123 | expect(mockAddMessage).toHaveBeenCalledWith({ 124 | role: 'assistant', 125 | content: "You've reached the rate limit. Please wait before sending more messages.", 126 | isError: true, 127 | retryAfter: 10 128 | }); 129 | }); 130 | 131 | it('handles general errors', async () => { 132 | // Mock error 133 | ChatService.sendMessage.mockRejectedValue(new Error('Server error')); 134 | 135 | const { result } = renderHook(() => useStreamingChat()); 136 | 137 | // Send a message 138 | await act(async () => { 139 | await result.current.sendMessage({ 140 | message: 'Test message' 141 | }); 142 | }); 143 | 144 | // Check that the error was set 145 | expect(result.current.error).toBe('Server error'); 146 | }); 147 | 148 | it('starts a new chat session', () => { 149 | const { result } = renderHook(() => useStreamingChat()); 150 | 151 | // Start a new chat 152 | act(() => { 153 | result.current.startNewChat(); 154 | }); 155 | 156 | // Check that messages were reset 157 | expect(mockResetMessages).toHaveBeenCalled(); 158 | 159 | // Check that a new session was started 160 | expect(mockStartNewSession).toHaveBeenCalled(); 161 | }); 162 | }); -------------------------------------------------------------------------------- /backend/app/middleware/rate_limiter.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Tuple 2 | from fastapi import Request, Response 3 | from redis import Redis 4 | from slowapi import Limiter 5 | from slowapi.util import get_remote_address 6 | from app.logs.logger import get_logger 7 | from app.models.data_structures import RateLimitResponse 8 | 9 | logger = get_logger(__name__) 10 | 11 | class RateLimiter: 12 | def __init__(self, redis_client: Redis, limiter: Limiter, global_rate: str, chat_rate: str): 13 | self.redis = redis_client 14 | self.limiter = limiter 15 | self.global_rate = global_rate 16 | self.chat_rate = chat_rate 17 | logger.info(f"Initializing RateLimiter with global_rate={global_rate}, chat_rate={chat_rate}") 18 | 19 | async def check_rate_limit(self, request: Request, response: Response) -> Optional[Response]: 20 | """Main rate limiting logic entry point""" 21 | try: 22 | if self._is_health_check(request): 23 | return None 24 | 25 | client_ip = get_remote_address(request) 26 | logger.debug(f"Checking rate limits for IP: {client_ip}") 27 | 28 | # Check global rate limit 29 | global_limit_response = await self._check_global_limit() 30 | if global_limit_response: 31 | return global_limit_response 32 | 33 | # Check chat-specific rate limit 34 | if self._is_chat_endpoint(request): 35 | chat_limit_response = await self._check_chat_limit(client_ip) 36 | if chat_limit_response: 37 | return chat_limit_response 38 | 39 | return None 40 | 41 | except Exception as e: 42 | logger.error(f"Rate limiting error: {str(e)}") 43 | return None # Fail open 44 | 45 | def _is_health_check(self, request: Request) -> bool: 46 | """Check if the request is for health check endpoints""" 47 | return request.url.path in ["/", "/health"] 48 | 49 | def _is_chat_endpoint(self, request: Request) -> bool: 50 | """Check if the request is for chat endpoints""" 51 | return request.url.path.startswith("/chat") 52 | 53 | async def _check_global_limit(self) -> Optional[Response]: 54 | """Check global rate limit""" 55 | global_key = "rate_limit:global" 56 | is_allowed, retry_after = await self._check_limit(global_key, self.global_rate) 57 | 58 | if not is_allowed: 59 | logger.warning(f"Global rate limit exceeded. Key: {global_key}") 60 | return self._create_limit_response( 61 | detail="Global rate limit exceeded", 62 | type="rate_limit_exceeded", 63 | limit=self.global_rate, 64 | retry_after=retry_after, 65 | friendly_message=f"You've reached the global rate limit. Please try again in {retry_after} seconds." 66 | ) 67 | return None 68 | 69 | async def _check_chat_limit(self, client_ip: str) -> Optional[Response]: 70 | """Check chat-specific rate limit""" 71 | chat_key = f"rate_limit:chat:{client_ip}" 72 | is_allowed, retry_after = await self._check_limit(chat_key, self.chat_rate) 73 | 74 | if not is_allowed: 75 | logger.warning(f"Chat rate limit exceeded for IP: {client_ip}. Key: {chat_key}") 76 | return self._create_limit_response( 77 | detail="Chat rate limit exceeded", 78 | type="chat_rate_limit_exceeded", 79 | limit=self.chat_rate, 80 | retry_after=retry_after, 81 | friendly_message="You're sending messages too quickly! Please wait before sending another message." 82 | ) 83 | return None 84 | 85 | def _create_limit_response(self, detail: str, type: str, limit: str, 86 | retry_after: int, friendly_message: str) -> Response: 87 | """Create a standardized rate limit response""" 88 | response_data = RateLimitResponse( 89 | detail=detail, 90 | type=type, 91 | limit=limit, 92 | retry_after=retry_after, 93 | friendly_message=friendly_message 94 | ) 95 | return Response( 96 | content=response_data.model_dump_json(), 97 | media_type="application/json", 98 | status_code=429 99 | ) 100 | 101 | async def _check_limit(self, key: str, limit: str) -> Tuple[bool, int]: 102 | """ 103 | Check if the request is within rate limits 104 | Returns (is_allowed, retry_after_seconds) 105 | """ 106 | count, period = self._parse_limit(limit) 107 | period_seconds = self._convert_period_to_seconds(period) 108 | 109 | # Use Redis for atomic increment and expire 110 | current = self.redis.incr(key) 111 | logger.debug(f"Rate limit check - Key: {key}, Current: {current}, Limit: {count}") 112 | 113 | if current == 1: 114 | self.redis.expire(key, period_seconds) 115 | logger.debug(f"Set expiry for key {key} to {period_seconds} seconds") 116 | 117 | # If we've exceeded the limit, get the TTL 118 | if current > count: 119 | retry_after = self.redis.ttl(key) 120 | logger.debug(f"Rate limit exceeded for key {key}. Retry after: {retry_after} seconds") 121 | return False, max(0, retry_after) 122 | 123 | return True, 0 124 | 125 | def _parse_limit(self, limit: str) -> Tuple[int, str]: 126 | """Parse rate limit string into count and period""" 127 | count, period = limit.split("/") 128 | return int(count), period 129 | 130 | def _convert_period_to_seconds(self, period: str) -> int: 131 | """Convert time period to seconds""" 132 | period_map = { 133 | "second": 1, 134 | "minute": 60, 135 | "hour": 3600, 136 | "day": 86400 137 | } 138 | return period_map.get(period, 86400) # default to day if unknown period -------------------------------------------------------------------------------- /frontend/src/components/layout/shared/SocialLinks.test.jsx: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, beforeEach } from 'vitest'; 2 | import { screen, fireEvent } from '@testing-library/react'; 3 | import { SocialLinks } from './SocialLinks'; 4 | import React from 'react'; 5 | import '../../../test/mocks'; 6 | import { renderWithConfig } from '../../../test/testUtils'; 7 | 8 | // Mock the getSocialLinksWithIcons and navConfig 9 | vi.mock('../config/navigationConfig', () => { 10 | const mockSocialLinks = [ 11 | { 12 | href: "https://github.com/user", 13 | icon: vi.fn(() =>
GitHub Icon
), 14 | label: "GitHub", 15 | color: "hover:text-[#2DA44E]" 16 | }, 17 | { 18 | href: "https://linkedin.com/in/user", 19 | icon: vi.fn(() =>
LinkedIn Icon
), 20 | label: "LinkedIn", 21 | color: "hover:text-[#0A66C2]" 22 | }, 23 | { 24 | href: "mailto:user@gmail.com", 25 | icon: vi.fn(() =>
Email Icon
), 26 | label: "Email", 27 | color: "hover:text-[#00C8DC]" 28 | } 29 | ]; 30 | 31 | return { 32 | getSocialLinksWithIcons: vi.fn(() => mockSocialLinks), 33 | navConfig: { 34 | iconAnimation: { 35 | whileHover: { scale: 1.1 }, 36 | whileTap: { scale: 0.95 } 37 | } 38 | } 39 | }; 40 | }); 41 | 42 | // Mock React's useEffect to execute immediately 43 | vi.mock('react', async () => { 44 | const actual = await vi.importActual('react'); 45 | return { 46 | ...actual, 47 | useEffect: (callback) => callback(), 48 | }; 49 | }); 50 | 51 | describe('SocialLinks', () => { 52 | beforeEach(() => { 53 | vi.clearAllMocks(); 54 | }); 55 | 56 | it('renders all social links', () => { 57 | renderWithConfig(); 58 | 59 | // Check that all social links are rendered 60 | expect(screen.getByTestId('github-icon')).toBeInTheDocument(); 61 | expect(screen.getByTestId('linkedin-icon')).toBeInTheDocument(); 62 | expect(screen.getByTestId('email-icon')).toBeInTheDocument(); 63 | }); 64 | 65 | it('renders links with correct href attributes', () => { 66 | renderWithConfig(); 67 | 68 | // Get all links 69 | const links = screen.getAllByRole('link'); 70 | 71 | // Check that the links have the correct href attributes 72 | expect(links[0]).toHaveAttribute('href', 'https://github.com/user'); 73 | expect(links[1]).toHaveAttribute('href', 'https://linkedin.com/in/user'); 74 | expect(links[2]).toHaveAttribute('href', 'mailto:user@gmail.com'); 75 | }); 76 | 77 | it('renders links with correct target and rel attributes', () => { 78 | renderWithConfig(); 79 | 80 | // Get all links 81 | const links = screen.getAllByRole('link'); 82 | 83 | // Check that the links have the correct target and rel attributes 84 | links.forEach(link => { 85 | expect(link).toHaveAttribute('target', '_blank'); 86 | expect(link).toHaveAttribute('rel', 'noopener noreferrer'); 87 | }); 88 | }); 89 | 90 | it('renders links with correct title attributes', () => { 91 | renderWithConfig(); 92 | 93 | // Get all links 94 | const links = screen.getAllByRole('link'); 95 | 96 | // Check that the links have the correct title attributes 97 | expect(links[0]).toHaveAttribute('title', 'GitHub'); 98 | expect(links[1]).toHaveAttribute('title', 'LinkedIn'); 99 | expect(links[2]).toHaveAttribute('title', 'Email'); 100 | }); 101 | 102 | it('does not show labels by default (desktop mode)', () => { 103 | renderWithConfig(); 104 | 105 | // Labels should not be visible in desktop mode 106 | expect(screen.queryByText('GitHub')).not.toBeInTheDocument(); 107 | expect(screen.queryByText('LinkedIn')).not.toBeInTheDocument(); 108 | expect(screen.queryByText('Email')).not.toBeInTheDocument(); 109 | }); 110 | 111 | it('shows labels in mobile mode', () => { 112 | renderWithConfig(); 113 | 114 | // Labels should be visible in mobile mode 115 | expect(screen.getByText('GitHub')).toBeInTheDocument(); 116 | expect(screen.getByText('LinkedIn')).toBeInTheDocument(); 117 | expect(screen.getByText('Email')).toBeInTheDocument(); 118 | }); 119 | 120 | it('applies different styling in mobile mode', () => { 121 | const { container: desktopContainer } = renderWithConfig(); 122 | 123 | // In desktop mode, the container should have gap-2 class and not have justify-around class 124 | const desktopDiv = desktopContainer.firstChild; 125 | expect(desktopDiv).toHaveClass('gap-2'); 126 | expect(desktopDiv).not.toHaveClass('justify-around'); 127 | expect(desktopDiv).not.toHaveClass('w-full'); 128 | 129 | // Cleanup and rerender in mobile mode 130 | const { container: mobileContainer } = renderWithConfig(); 131 | 132 | // In mobile mode, the container should have justify-around and w-full classes 133 | const mobileDiv = mobileContainer.firstChild; 134 | expect(mobileDiv).toHaveClass('justify-around'); 135 | expect(mobileDiv).toHaveClass('w-full'); 136 | expect(mobileDiv).not.toHaveClass('gap-2'); 137 | }); 138 | 139 | it('has click handlers that stop event propagation', () => { 140 | // Mock the implementation of e.stopPropagation 141 | const originalStopPropagation = Event.prototype.stopPropagation; 142 | const mockStopPropagation = vi.fn(); 143 | Event.prototype.stopPropagation = mockStopPropagation; 144 | 145 | renderWithConfig(); 146 | 147 | // Get all links 148 | const links = screen.getAllByRole('link'); 149 | 150 | // Simulate a click on the first link 151 | fireEvent.click(links[0]); 152 | 153 | // Check that stopPropagation was called 154 | expect(mockStopPropagation).toHaveBeenCalled(); 155 | 156 | // Restore the original implementation 157 | Event.prototype.stopPropagation = originalStopPropagation; 158 | }); 159 | }); --------------------------------------------------------------------------------