diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml new file mode 100644 index 0000000..b475078 --- /dev/null +++ b/.github/workflows/backend.yml @@ -0,0 +1,69 @@ +name: Backend CI + +on: + push: + branches: [ main, develop ] + paths: + - 'backend/**' + - '.github/workflows/backend.yml' + pull_request: + branches: [ main, develop ] + paths: + - 'backend/**' + - '.github/workflows/backend.yml' + +jobs: + backend-test: + runs-on: [docker] + + services: + postgres: + image: postgres:15-alpine + env: + POSTGRES_USER: test + POSTGRES_PASSWORD: test + POSTGRES_DB: test_db + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + container: + image: nikolaik/python-nodejs:python3.12-nodejs24-alpine + options: --volume forgejo-pip-cache:/tmp/pip-cache + + steps: + - uses: actions/checkout@v6 + + - name: Set up Python + run: | + python --version + + - name: Install dependencies + run: | + cd backend + python -m pip install --upgrade pip + pip install --cache-dir /tmp/pip-cache -r requirements/dev.txt + + - name: Debug cache + run: | + echo "Listing PIP cache files:" + pip cache dir + ls -la /tmp/pip-cache 2>/dev/null || echo "Cache dir empty or missing" + + - name: Lint with flake8 + run: | + cd backend + flake8 app tests --count --max-complexity=10 --max-line-length=127 --statistics --show-source + + - name: Run tests + env: + DATABASE_URL: postgresql://test:test@postgres:5432/test_db + SECRET_KEY: test-secret-key + JWT_SECRET_KEY: test-jwt-secret + FLASK_ENV: testing + run: | + cd backend + pytest --cov=app --cov-report=xml --cov-report=term + \ No newline at end of file diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index c984cb2..35fe51c 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -1,56 +1,56 @@ -name: CD +# name: CD -on: - push: - branches: [ main ] - workflow_dispatch: +# on: +# push: +# branches: [ main ] +# workflow_dispatch: -jobs: - deploy: - runs-on: [docker] +# jobs: +# deploy: +# runs-on: [docker] - steps: - - uses: actions/checkout@v3 +# steps: +# - uses: actions/checkout@v3 - - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v2 - with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - aws-region: ${{ secrets.AWS_REGION }} +# - name: Configure AWS credentials +# uses: aws-actions/configure-aws-credentials@v2 +# with: +# aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} +# aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} +# aws-region: ${{ secrets.AWS_REGION }} - - name: Login to Amazon ECR - id: login-ecr - uses: aws-actions/amazon-ecr-login@v1 +# - name: Login to Amazon ECR +# id: login-ecr +# uses: aws-actions/amazon-ecr-login@v1 - - name: Build and push backend - env: - ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} - ECR_REPOSITORY: crafting-shop-backend - IMAGE_TAG: ${{ github.sha }} - run: | - cd backend - docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG . - docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG - docker tag $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG $ECR_REGISTRY/$ECR_REPOSITORY:latest - docker push $ECR_REGISTRY/$ECR_REPOSITORY:latest +# - name: Build and push backend +# env: +# ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} +# ECR_REPOSITORY: crafting-shop-backend +# IMAGE_TAG: ${{ github.sha }} +# run: | +# cd backend +# docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG . +# docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG +# docker tag $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG $ECR_REGISTRY/$ECR_REPOSITORY:latest +# docker push $ECR_REGISTRY/$ECR_REPOSITORY:latest - - name: Build and push frontend - env: - ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} - ECR_REPOSITORY: crafting-shop-frontend - IMAGE_TAG: ${{ github.sha }} - run: | - cd frontend - docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG . - docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG - docker tag $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG $ECR_REGISTRY/$ECR_REPOSITORY:latest - docker push $ECR_REGISTRY/$ECR_REPOSITORY:latest +# - name: Build and push frontend +# env: +# ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} +# ECR_REPOSITORY: crafting-shop-frontend +# IMAGE_TAG: ${{ github.sha }} +# run: | +# cd frontend +# docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG . +# docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG +# docker tag $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG $ECR_REGISTRY/$ECR_REPOSITORY:latest +# docker push $ECR_REGISTRY/$ECR_REPOSITORY:latest - - name: Deploy to ECS - uses: aws-actions/amazon-ecs-deploy-task-definition@v1 - with: - task-definition: crafting-shop-task - service: crafting-shop-service - cluster: crafting-shop-cluster - wait-for-service-stability: true \ No newline at end of file +# - name: Deploy to ECS +# uses: aws-actions/amazon-ecs-deploy-task-definition@v1 +# with: +# task-definition: crafting-shop-task +# service: crafting-shop-service +# cluster: crafting-shop-cluster +# wait-for-service-stability: true \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 4db3c51..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,123 +0,0 @@ -name: CI - -on: - push: - branches: [ main, develop ] - pull_request: - branches: [ main, develop ] - -jobs: - backend-test: - runs-on: [docker] - - services: - postgres: - image: postgres:15-alpine - env: - POSTGRES_USER: test - POSTGRES_PASSWORD: test - POSTGRES_DB: test_db - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - ports: - - 5432:5432 - - steps: - - uses: actions/checkout@v3 - - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: '3.11' - - - name: Cache pip packages - uses: actions/cache@v3 - with: - path: ~/.cache/pip - key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} - restore-keys: | - ${{ runner.os }}-pip- - - - name: Install dependencies - run: | - cd backend - python -m pip install --upgrade pip - pip install -r requirements/dev.txt - - - name: Lint with flake8 - run: | - cd backend - flake8 app tests --count --select=E9,F63,F7,F82 --show-source --statistics - flake8 app tests --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - - - name: Run tests - env: - DATABASE_URL: postgresql://test:test@localhost:5432/test_db - SECRET_KEY: test-secret-key - JWT_SECRET_KEY: test-jwt-secret - FLASK_ENV: testing - run: | - cd backend - pytest --cov=app --cov-report=xml --cov-report=term - - - name: Upload coverage - uses: codecov/codecov-action@v3 - with: - files: ./backend/coverage.xml - flags: backend - name: backend-coverage - - frontend-test: - runs-on: [docker] - - steps: - - uses: actions/checkout@v3 - - - name: Set up Node.js - uses: actions/setup-node@v3 - with: - node-version: '18' - cache: 'npm' - cache-dependency-path: frontend/package-lock.json - - - name: Install dependencies - run: | - cd frontend - npm ci - - - name: Lint - run: | - cd frontend - npm run lint - - - name: Run tests - run: | - cd frontend - npm test -- --run --coverage - - - name: Upload coverage - uses: codecov/codecov-action@v3 - with: - files: ./frontend/coverage/coverage-final.json - flags: frontend - name: frontend-coverage - - build: - runs-on: [docker] - needs: [backend-test, frontend-test] - - steps: - - uses: actions/checkout@v3 - - - name: Build backend - run: | - cd backend - docker build -t crafting-shop-backend:test . - - - name: Build frontend - run: | - cd frontend - docker build -t crafting-shop-frontend:test . \ No newline at end of file diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml new file mode 100644 index 0000000..e75c321 --- /dev/null +++ b/.github/workflows/frontend.yml @@ -0,0 +1,39 @@ +name: Frontend CI + +on: + push: + branches: [ main, develop ] + paths: + - 'frontend/**' + - '.github/workflows/frontend.yml' + pull_request: + branches: [ main, develop ] + paths: + - 'frontend/**' + - '.github/workflows/frontend.yml' + +jobs: + frontend-test: + runs-on: [docker] + + # Note: Using the container here ensures the cache volume works reliably + container: + image: nikolaik/python-nodejs:python3.12-nodejs24-alpine + options: --volume forgejo-npm-cache:/tmp/npm-cache + + steps: + - uses: actions/checkout@v6 + + - name: Configure NPM cache + run: npm config set cache /tmp/npm-cache --global + + - name: Install dependencies + run: | + cd frontend + npm config get cache + npm ci + + - name: Lint + run: | + cd frontend + npm run lint diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..3d8cba5 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,65 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: trailing-whitespace + exclude: ^.+\.md$ + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + args: ['--maxkb=1000'] + - id: check-json + - id: check-toml + - id: check-merge-conflict + - id: debug-statements + language: python + + - repo: https://github.com/psf/black + rev: 23.12.1 + hooks: + - id: black + language_version: python3.11 + args: ['--line-length=100'] + + - repo: https://github.com/pycqa/isort + rev: 5.13.2 + hooks: + - id: isort + args: ['--profile=black', '--line-length=100'] + + - repo: https://github.com/pycqa/flake8 + rev: 7.0.0 + hooks: + - id: flake8 + args: ['--max-line-length=100', '--extend-ignore=E203,W503'] + additional_dependencies: [ + flake8-docstrings, + flake8-bugbear, + flake8-comprehensions, + ] + + - repo: local + hooks: + - id: pytest + name: pytest + entry: pytest + language: system + pass_filenames: false + args: ['backend/', '-v', '--tb=short'] + always_run: true + + - id: type-check + name: mypy type check + entry: mypy + language: system + pass_filenames: false + args: ['backend/app/'] + always_run: false + + - id: security-check + name: bandit security check + entry: bandit + language: system + pass_filenames: false + args: ['-r', 'backend/app/', '-ll'] + always_run: false \ No newline at end of file diff --git a/Makefile b/Makefile index 3c5aed0..facda8b 100644 --- a/Makefile +++ b/Makefile @@ -67,6 +67,48 @@ test: ## Run all tests test-backend: ## Run backend tests only cd backend && . venv/bin/activate && pytest +test-backend-cov: ## Run backend tests with coverage + cd backend && . venv/bin/activate && pytest --cov=app --cov-report=html --cov-report=term + +test-backend-verbose: ## Run backend tests with verbose output + cd backend && . venv/bin/activate && pytest -v + +test-backend-unit: ## Run backend unit tests only + cd backend && . venv/bin/activate && pytest -m unit + +test-backend-integration: ## Run backend integration tests only + cd backend && . venv/bin/activate && pytest -m integration + +test-backend-auth: ## Run backend authentication tests only + cd backend && . venv/bin/activate && pytest -m auth + +test-backend-product: ## Run backend product tests only + cd backend && . venv/bin/activate && pytest -m product + +test-backend-order: ## Run backend order tests only + cd backend && . venv/bin/activate && pytest -m order + +test-backend-watch: ## Run backend tests in watch mode (auto-rerun on changes) + cd backend && . venv/bin/activate && pip install pytest-watch && pytest-watch + +test-backend-parallel: ## Run backend tests in parallel (faster) + cd backend && . venv/bin/activate && pip install pytest-xdist && pytest -n auto + +test-backend-coverage-report: ## Open backend coverage report in browser + cd backend && . venv/bin/activate && pytest --cov=app --cov-report=html && python -m webbrowser htmlcov/index.html + +test-backend-failed: ## Re-run only failed backend tests + cd backend && . venv/bin/activate && pytest --lf + +test-backend-last-failed: ## Run the tests that failed in the last run + cd backend && . venv/bin/activate && pytest --lf + +test-backend-specific: ## Run specific backend test (usage: make test-backend-specific TEST=test_models.py) + cd backend && . venv/bin/activate && pytest tests/$(TEST) + +test-backend-marker: ## Run backend tests by marker (usage: make test-backend-marker MARKER=auth) + cd backend && . venv/bin/activate && pytest -m $(MARKER) + test-frontend: ## Run frontend tests only cd frontend && npm test @@ -84,7 +126,23 @@ lint-frontend: ## Lint frontend only format: ## Format code @echo "Formatting backend..." - cd backend && . venv/bin/activate && black app tests && isort app tests + cd backend && . venv/bin/activate && black app tests + cd backend && . venv/bin/activate && isort app tests + @echo "Formatting frontend..." + cd frontend && npx prettier --write "src/**/*.{js,jsx,ts,tsx,css}" + +format-backend: ## Format backend code only + @echo "Formatting backend with black..." + cd backend && . venv/bin/activate && black app tests + @echo "Sorting imports with isort..." + cd backend && . venv/bin/activate && isort app tests + +format-backend-check: ## Check if backend code needs formatting + @echo "Checking backend formatting..." + cd backend && . venv/bin/activate && black --check app tests + cd backend && . venv/bin/activate && isort --check-only app tests + +format-frontend: ## Format frontend code only @echo "Formatting frontend..." cd frontend && npx prettier --write "src/**/*.{js,jsx,ts,tsx,css}" diff --git a/backend/.coveragerc b/backend/.coveragerc new file mode 100644 index 0000000..4c95bdf --- /dev/null +++ b/backend/.coveragerc @@ -0,0 +1,23 @@ +[run] +source = app +omit = + */tests/* + */migrations/* + */__pycache__/* + */venv/* + */instance/* + app/__init__.py + +[report] +exclude_lines = + pragma: no cover + def __repr__ + raise NotImplementedError + if __name__ == .__main__.: + if TYPE_CHECKING: + @abstractmethod + pass +precision = 2 + +[html] +directory = htmlcov \ No newline at end of file diff --git a/backend/app/__init__.py b/backend/app/__init__.py index abf70d4..df448d6 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -1,11 +1,13 @@ import json +import os + +from dotenv import load_dotenv from flask import Flask, jsonify from flask_cors import CORS from flask_jwt_extended import JWTManager -from flask_sqlalchemy import SQLAlchemy from flask_migrate import Migrate -import os -from dotenv import load_dotenv +from flask_sqlalchemy import SQLAlchemy + # Create extensions but don't initialize them yet db = SQLAlchemy() migrate = Migrate() @@ -13,47 +15,62 @@ jwt = JWTManager() cors = CORS() load_dotenv(override=True) + def create_app(config_name=None): """Application factory pattern""" app = Flask(__name__) - + # Load configuration if config_name is None: config_name = os.environ.get("FLASK_ENV", "development") - + from app.config import config_by_name + app.config.from_object(config_by_name[config_name]) - - print('----------------------------------------------------------') - print(F'------------------ENVIRONMENT: {config_name}-------------------------------------') + + print("----------------------------------------------------------") + print( + f"------------------ENVIRONMENT: {config_name}-------------------------------------" + ) # print(F'------------------CONFIG: {app.config}-------------------------------------') - # print(json.dumps(dict(app.config), indent=2, default=str)) - print('----------------------------------------------------------') + print(json.dumps(dict(app.config), indent=2, default=str)) + print("----------------------------------------------------------") # Initialize extensions with app db.init_app(app) migrate.init_app(app, db) jwt.init_app(app) - cors.init_app(app, resources={r"/api/*": {"origins": app.config.get("CORS_ORIGINS", "*")}}) - + cors.init_app( + app, resources={r"/api/*": {"origins": app.config.get("CORS_ORIGINS", "*")}} + ) + # Initialize Celery from app.celery import init_celery + init_celery(app) - + # Import models (required for migrations) - from app.models import user, product, order - + from app.models import order, product, user # noqa: F401 + # Register blueprints from app.routes import api_bp, health_bp + app.register_blueprint(api_bp, url_prefix="/api") app.register_blueprint(health_bp) - + # Global error handlers @app.errorhandler(404) def not_found(error): + print(f"404 Error: {error}") return jsonify({"error": "Not found"}), 404 - + @app.errorhandler(500) def internal_error(error): + print(f"500 Error: {error}") return jsonify({"error": "Internal server error"}), 500 - + + @app.errorhandler(422) + def validation_error(error): + print(f"422 Error: {error}") + return jsonify({"error": "Validation error"}), 422 + return app diff --git a/backend/app/celery/__init__.py b/backend/app/celery/__init__.py index 7e4589a..b0e8600 100644 --- a/backend/app/celery/__init__.py +++ b/backend/app/celery/__init__.py @@ -9,10 +9,10 @@ from flask import Flask def make_celery(app: Flask) -> Celery: """ Create and configure a Celery application with Flask context. - + Args: app: Flask application instance - + Returns: Configured Celery application instance. """ @@ -20,36 +20,38 @@ def make_celery(app: Flask) -> Celery: celery_app = Celery( app.import_name, broker=app.config["CELERY"]["broker_url"], - backend=app.config["CELERY"]["result_backend"] + backend=app.config["CELERY"]["result_backend"], ) - + # Update configuration from Flask config celery_app.conf.update(app.config["CELERY"]) - + # Set up Flask application context for tasks # This ensures tasks have access to Flask extensions (db, etc.) class ContextTask(celery_app.Task): """Celery task that runs within Flask application context.""" + def __call__(self, *args, **kwargs): with app.app_context(): return self.run(*args, **kwargs) - + celery_app.Task = ContextTask - + # Auto-discover tasks in the tasks module - celery_app.autodiscover_tasks(['app.celery.tasks']) - + celery_app.autodiscover_tasks(["app.celery.tasks"]) + # Configure Beat schedule from .beat_schedule import configure_beat_schedule + configure_beat_schedule(celery_app) - + # Import tasks to ensure they're registered - from .tasks import example_tasks - + from .tasks import example_tasks # noqa: F401 + print(f"✅ Celery configured with broker: {celery_app.conf.broker_url}") print(f"✅ Celery configured with backend: {celery_app.conf.result_backend}") print(f"✅ Beat schedule configured with {len(celery_app.conf.beat_schedule)} tasks") - + return celery_app @@ -61,10 +63,10 @@ def init_celery(app: Flask) -> Celery: """ Initialize the global celery instance with Flask app. This should be called in create_app() after Flask app is created. - + Args: app: Flask application instance - + Returns: Configured Celery application instance """ diff --git a/backend/app/celery/beat_schedule.py b/backend/app/celery/beat_schedule.py index 620bbc4..331f8e7 100644 --- a/backend/app/celery/beat_schedule.py +++ b/backend/app/celery/beat_schedule.py @@ -4,7 +4,6 @@ This defines when scheduled tasks should run. """ from celery.schedules import crontab - # Celery Beat schedule configuration beat_schedule = { # Run every minute (for testing/demo) @@ -14,14 +13,12 @@ beat_schedule = { "args": ("Celery Beat",), "options": {"queue": "default"}, }, - # Run daily at 9:00 AM "send-daily-report": { "task": "tasks.send_daily_report", "schedule": crontab(hour=9, minute=0), # 9:00 AM daily "options": {"queue": "reports"}, }, - # Run every hour at minute 0 "update-product-stats-hourly": { "task": "tasks.update_product_statistics", @@ -29,7 +26,6 @@ beat_schedule = { "args": (None,), # Update all products "options": {"queue": "stats"}, }, - # Run every Monday at 8:00 AM "weekly-maintenance": { "task": "tasks.long_running_task", @@ -37,7 +33,6 @@ beat_schedule = { "args": (5,), # 5 iterations "options": {"queue": "maintenance"}, }, - # Run every 5 minutes (for monitoring/heartbeat) "heartbeat-check": { "task": "tasks.print_hello", @@ -51,16 +46,16 @@ beat_schedule = { def configure_beat_schedule(celery_app): """ Configure Celery Beat schedule on the Celery app. - + Args: celery_app: Celery application instance """ celery_app.conf.beat_schedule = beat_schedule - + # Configure timezone celery_app.conf.timezone = "UTC" celery_app.conf.enable_utc = True - + # Configure task routes for scheduled tasks celery_app.conf.task_routes = { "tasks.print_hello": {"queue": "default"}, @@ -68,7 +63,7 @@ def configure_beat_schedule(celery_app): "tasks.update_product_statistics": {"queue": "stats"}, "tasks.long_running_task": {"queue": "maintenance"}, } - + # Configure queues celery_app.conf.task_queues = { "default": { @@ -96,4 +91,4 @@ def configure_beat_schedule(celery_app): "exchange_type": "direct", "routing_key": "monitoring", }, - } \ No newline at end of file + } diff --git a/backend/app/celery/tasks/__init__.py b/backend/app/celery/tasks/__init__.py index febf25b..f58ad37 100644 --- a/backend/app/celery/tasks/__init__.py +++ b/backend/app/celery/tasks/__init__.py @@ -4,12 +4,12 @@ Tasks are organized by domain/functionality. """ # Import all task modules here to ensure they're registered with Celery -from . import example_tasks +from . import example_tasks # noqa: F401 # Re-export tasks for easier imports -from .example_tasks import ( - print_hello, +from .example_tasks import ( # noqa: F401 divide_numbers, + print_hello, send_daily_report, update_product_statistics, -) \ No newline at end of file +) diff --git a/backend/app/celery/tasks/example_tasks.py b/backend/app/celery/tasks/example_tasks.py index 30bb841..358e1ae 100644 --- a/backend/app/celery/tasks/example_tasks.py +++ b/backend/app/celery/tasks/example_tasks.py @@ -2,11 +2,11 @@ Example Celery tasks for the Crafting Shop application. These tasks demonstrate various Celery features and best practices. """ -import time import logging +import time from datetime import datetime + from celery import shared_task -from celery.exceptions import MaxRetriesExceededError # Get logger for this module logger = logging.getLogger(__name__) @@ -16,10 +16,10 @@ logger = logging.getLogger(__name__) def print_hello(self, name: str = "World") -> str: """ Simple task that prints a greeting. - + Args: name: Name to greet (default: "World") - + Returns: Greeting message """ @@ -36,51 +36,47 @@ def print_hello(self, name: str = "World") -> str: retry_backoff=True, retry_backoff_max=60, retry_jitter=True, - max_retries=3 + max_retries=3, ) def divide_numbers(self, x: float, y: float) -> float: """ Task that demonstrates error handling and retry logic. - + Args: x: Numerator y: Denominator - + Returns: Result of division - + Raises: ZeroDivisionError: If y is zero (will trigger retry) """ logger.info(f"Dividing {x} by {y} (attempt {self.request.retries + 1})") - + if y == 0: - logger.warning(f"Division by zero detected, retrying...") + logger.warning("Division by zero detected, retrying...") raise ZeroDivisionError("Cannot divide by zero") - + result = x / y logger.info(f"Division result: {result}") return result -@shared_task( - bind=True, - name="tasks.send_daily_report", - ignore_result=False -) +@shared_task(bind=True, name="tasks.send_daily_report", ignore_result=False) def send_daily_report(self) -> dict: """ Simulates sending a daily report. This task would typically send emails, generate reports, etc. - + Returns: Dictionary with report details """ logger.info("Starting daily report generation...") - + # Simulate some work time.sleep(2) - + report_data = { "date": datetime.now().isoformat(), "task_id": self.request.id, @@ -90,46 +86,43 @@ def send_daily_report(self) -> dict: "total_products": 150, "total_orders": 42, "total_users": 89, - "revenue": 12500.75 - } + "revenue": 12500.75, + }, } - + logger.info(f"Daily report generated: {report_data}") print(f"📊 Daily Report Generated at {report_data['date']}") - + return report_data @shared_task( - bind=True, - name="tasks.update_product_statistics", - queue="stats", - priority=5 + bind=True, name="tasks.update_product_statistics", queue="stats", priority=5 ) def update_product_statistics(self, product_id: int = None) -> dict: """ Simulates updating product statistics. Demonstrates task routing to a specific queue. - + Args: product_id: Optional specific product ID to update. If None, updates all products. - + Returns: Dictionary with update results """ logger.info(f"Updating product statistics for product_id={product_id}") - + # Simulate database work time.sleep(1) - + if product_id is None: # Update all products result = { "task": "update_all_product_stats", "status": "completed", "products_updated": 150, - "timestamp": datetime.now().isoformat() + "timestamp": datetime.now().isoformat(), } else: # Update specific product @@ -138,13 +131,9 @@ def update_product_statistics(self, product_id: int = None) -> dict: "product_id": product_id, "status": "completed", "timestamp": datetime.now().isoformat(), - "new_stats": { - "views": 125, - "purchases": 15, - "rating": 4.5 - } + "new_stats": {"views": 125, "purchases": 15, "rating": 4.5}, } - + logger.info(f"Product statistics updated: {result}") return result @@ -153,46 +142,46 @@ def update_product_statistics(self, product_id: int = None) -> dict: bind=True, name="tasks.long_running_task", time_limit=300, # 5 minutes - soft_time_limit=240 # 4 minutes + soft_time_limit=240, # 4 minutes ) def long_running_task(self, iterations: int = 10) -> dict: """ Simulates a long-running task with progress tracking. - + Args: iterations: Number of iterations to simulate - + Returns: Dictionary with results """ logger.info(f"Starting long-running task with {iterations} iterations") - + results = [] for i in range(iterations): # Check if task has been revoked if self.is_aborted(): logger.warning("Task was aborted") return {"status": "aborted", "completed_iterations": i} - + # Simulate work time.sleep(1) - + # Update progress progress = (i + 1) / iterations * 100 self.update_state( state="PROGRESS", - meta={"current": i + 1, "total": iterations, "progress": progress} + meta={"current": i + 1, "total": iterations, "progress": progress}, ) - + results.append(f"iteration_{i + 1}") logger.info(f"Completed iteration {i + 1}/{iterations}") - + final_result = { "status": "completed", "iterations": iterations, "results": results, - "completed_at": datetime.now().isoformat() + "completed_at": datetime.now().isoformat(), } - + logger.info(f"Long-running task completed: {final_result}") - return final_result \ No newline at end of file + return final_result diff --git a/backend/app/config.py b/backend/app/config.py index fc3de1b..69fc836 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -4,17 +4,20 @@ from datetime import timedelta class Config: """Base configuration""" + SECRET_KEY = os.environ.get("SECRET_KEY") or "dev-secret-key-change-in-production" SQLALCHEMY_TRACK_MODIFICATIONS = False - JWT_SECRET_KEY = os.environ.get("JWT_SECRET_KEY") or "jwt-secret-key-change-in-production" + JWT_SECRET_KEY = os.environ["JWT_SECRET_KEY"] JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=1) JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=30) CORS_ORIGINS = os.environ.get("CORS_ORIGINS", "*") - + # Celery Configuration CELERY = { "broker_url": os.environ.get("CELERY_BROKER_URL", "redis://redis:6379/0"), - "result_backend": os.environ.get("CELERY_RESULT_BACKEND", "redis://redis:6379/0"), + "result_backend": os.environ.get( + "CELERY_RESULT_BACKEND", "redis://redis:6379/0" + ), "task_serializer": "json", "result_serializer": "json", "accept_content": ["json"], @@ -31,12 +34,14 @@ class Config: class DevelopmentConfig(Config): """Development configuration""" + DEBUG = True SQLALCHEMY_DATABASE_URI = os.environ.get("DEV_DATABASE_URL") or "sqlite:///dev.db" class TestingConfig(Config): """Testing configuration""" + TESTING = True SQLALCHEMY_DATABASE_URI = os.environ.get("TEST_DATABASE_URL") or "sqlite:///test.db" WTF_CSRF_ENABLED = False @@ -44,9 +49,12 @@ class TestingConfig(Config): class ProductionConfig(Config): """Production configuration""" + DEBUG = False - SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL") or "postgresql://user:password@localhost/proddb" - + SQLALCHEMY_DATABASE_URI = ( + os.environ.get("DATABASE_URL") or "postgresql://user:password@localhost/proddb" + ) + # Security headers SESSION_COOKIE_SECURE = True SESSION_COOKIE_HTTPONLY = True @@ -56,5 +64,5 @@ class ProductionConfig(Config): config_by_name = { "dev": DevelopmentConfig, "test": TestingConfig, - "prod": ProductionConfig -} \ No newline at end of file + "prod": ProductionConfig, +} diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index eb5e750..713196e 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,5 +1,5 @@ -from app.models.user import User -from app.models.product import Product from app.models.order import Order, OrderItem +from app.models.product import Product +from app.models.user import User -__all__ = ["User", "Product", "Order", "OrderItem"] \ No newline at end of file +__all__ = ["User", "Product", "Order", "OrderItem"] diff --git a/backend/app/models/order.py b/backend/app/models/order.py index 8ed3876..dd690e4 100644 --- a/backend/app/models/order.py +++ b/backend/app/models/order.py @@ -1,23 +1,34 @@ -from datetime import datetime +from datetime import UTC, datetime + from app import db class Order(db.Model): """Order model""" + __tablename__ = "orders" - + id = db.Column(db.Integer, primary_key=True) user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) status = db.Column(db.String(20), default="pending", index=True) total_amount = db.Column(db.Numeric(10, 2), nullable=False) shipping_address = db.Column(db.Text) - created_at = db.Column(db.DateTime, default=datetime.utcnow) - updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) - + created_at = db.Column(db.DateTime, default=lambda: datetime.now(UTC)) + updated_at = db.Column( + db.DateTime, + default=lambda: datetime.now(UTC), + onupdate=lambda: datetime.now(UTC), + ) + # Relationships user = db.relationship("User", back_populates="orders") - items = db.relationship("OrderItem", back_populates="order", lazy="dynamic", cascade="all, delete-orphan") - + items = db.relationship( + "OrderItem", + back_populates="order", + lazy="dynamic", + cascade="all, delete-orphan", + ) + def to_dict(self): """Convert order to dictionary""" return { @@ -28,27 +39,28 @@ class Order(db.Model): "shipping_address": self.shipping_address, "created_at": self.created_at.isoformat() if self.created_at else None, "updated_at": self.updated_at.isoformat() if self.updated_at else None, - "items": [item.to_dict() for item in self.items] + "items": [item.to_dict() for item in self.items], } - + def __repr__(self): return f"" class OrderItem(db.Model): """Order Item model""" + __tablename__ = "order_items" - + id = db.Column(db.Integer, primary_key=True) order_id = db.Column(db.Integer, db.ForeignKey("orders.id"), nullable=False) product_id = db.Column(db.Integer, db.ForeignKey("products.id"), nullable=False) quantity = db.Column(db.Integer, nullable=False) price = db.Column(db.Numeric(10, 2), nullable=False) - + # Relationships order = db.relationship("Order", back_populates="items") product = db.relationship("Product", back_populates="order_items") - + def to_dict(self): """Convert order item to dictionary""" return { @@ -56,8 +68,8 @@ class OrderItem(db.Model): "order_id": self.order_id, "product_id": self.product_id, "quantity": self.quantity, - "price": float(self.price) if self.price else None + "price": float(self.price) if self.price else None, } - + def __repr__(self): - return f"" \ No newline at end of file + return f"" diff --git a/backend/app/models/product.py b/backend/app/models/product.py index 408644e..94c6303 100644 --- a/backend/app/models/product.py +++ b/backend/app/models/product.py @@ -1,11 +1,13 @@ -from datetime import datetime +from datetime import UTC, datetime + from app import db class Product(db.Model): """Product model""" + __tablename__ = "products" - + id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(200), nullable=False, index=True) description = db.Column(db.Text) @@ -13,12 +15,16 @@ class Product(db.Model): stock = db.Column(db.Integer, default=0) image_url = db.Column(db.String(500)) is_active = db.Column(db.Boolean, default=True) - created_at = db.Column(db.DateTime, default=datetime.utcnow) - updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) - + created_at = db.Column(db.DateTime, default=lambda: datetime.now(UTC)) + updated_at = db.Column( + db.DateTime, + default=lambda: datetime.now(UTC), + onupdate=lambda: datetime.now(UTC), + ) + # Relationships order_items = db.relationship("OrderItem", back_populates="product", lazy="dynamic") - + def to_dict(self): """Convert product to dictionary""" return { @@ -30,8 +36,8 @@ class Product(db.Model): "image_url": self.image_url, "is_active": self.is_active, "created_at": self.created_at.isoformat() if self.created_at else None, - "updated_at": self.updated_at.isoformat() if self.updated_at else None + "updated_at": self.updated_at.isoformat() if self.updated_at else None, } - + def __repr__(self): - return f"" \ No newline at end of file + return f"" diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 40bd00e..72fbcdc 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -1,12 +1,15 @@ -from datetime import datetime -from werkzeug.security import generate_password_hash, check_password_hash +from datetime import UTC, datetime + +from werkzeug.security import check_password_hash, generate_password_hash + from app import db class User(db.Model): """User model""" + __tablename__ = "users" - + id = db.Column(db.Integer, primary_key=True) email = db.Column(db.String(120), unique=True, nullable=False, index=True) username = db.Column(db.String(80), unique=True, nullable=False, index=True) @@ -15,20 +18,24 @@ class User(db.Model): last_name = db.Column(db.String(50)) is_active = db.Column(db.Boolean, default=True) is_admin = db.Column(db.Boolean, default=False) - created_at = db.Column(db.DateTime, default=datetime.utcnow) - updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) - + created_at = db.Column(db.DateTime, default=lambda: datetime.now(UTC)) + updated_at = db.Column( + db.DateTime, + default=lambda: datetime.now(UTC), + onupdate=lambda: datetime.now(UTC), + ) + # Relationships orders = db.relationship("Order", back_populates="user", lazy="dynamic") - + def set_password(self, password): """Hash and set password""" self.password_hash = generate_password_hash(password) - + def check_password(self, password): """Check if provided password matches hash""" return check_password_hash(self.password_hash, password) - + def to_dict(self): """Convert user to dictionary""" return { @@ -40,8 +47,8 @@ class User(db.Model): "is_active": self.is_active, "is_admin": self.is_admin, "created_at": self.created_at.isoformat() if self.created_at else None, - "updated_at": self.updated_at.isoformat() if self.updated_at else None + "updated_at": self.updated_at.isoformat() if self.updated_at else None, } - + def __repr__(self): - return f"" \ No newline at end of file + return f"" diff --git a/backend/app/routes/__init__.py b/backend/app/routes/__init__.py index 8ca47da..439d3ac 100644 --- a/backend/app/routes/__init__.py +++ b/backend/app/routes/__init__.py @@ -1,4 +1,4 @@ from .api import api_bp from .health import health_bp -__all__ = ["api_bp", "health_bp"] \ No newline at end of file +__all__ = ["api_bp", "health_bp"] diff --git a/backend/app/routes/api.py b/backend/app/routes/api.py index 1f989b8..e60344a 100644 --- a/backend/app/routes/api.py +++ b/backend/app/routes/api.py @@ -1,12 +1,15 @@ -import time -from decimal import Decimal +from flask import Blueprint, jsonify, request +from flask_jwt_extended import ( + create_access_token, + create_refresh_token, + get_jwt_identity, + jwt_required, +) from pydantic import ValidationError -from flask import Blueprint, request, jsonify -from flask_jwt_extended import jwt_required, get_jwt_identity, create_access_token, create_refresh_token from app import db -from app.models import User, Product, OrderItem, Order from app.celery import celery +from app.models import Order, OrderItem, Product, User from app.schemas import ProductCreateRequest, ProductResponse api_bp = Blueprint("api", __name__) @@ -17,24 +20,24 @@ api_bp = Blueprint("api", __name__) def register(): """Register a new user""" data = request.get_json() - + if not data or not data.get("email") or not data.get("password"): return jsonify({"error": "Email and password are required"}), 400 - + if User.query.filter_by(email=data["email"]).first(): return jsonify({"error": "Email already exists"}), 400 - + user = User( email=data["email"], username=data.get("username", data["email"].split("@")[0]), first_name=data.get("first_name"), - last_name=data.get("last_name") + last_name=data.get("last_name"), ) user.set_password(data["password"]) - + db.session.add(user) db.session.commit() - + return jsonify(user.to_dict()), 201 @@ -42,38 +45,43 @@ def register(): def login(): """Login user""" data = request.get_json() - + if not data or not data.get("email") or not data.get("password"): return jsonify({"error": "Email and password are required"}), 400 - + user = User.query.filter_by(email=data["email"]).first() - + if not user or not user.check_password(data["password"]): return jsonify({"error": "Invalid credentials"}), 401 - + if not user.is_active: return jsonify({"error": "Account is inactive"}), 401 - - access_token = create_access_token(identity=user.id) - refresh_token = create_refresh_token(identity=user.id) - - return jsonify({ - "user": user.to_dict(), - "access_token": access_token, - "refresh_token": refresh_token - }), 200 + + access_token = create_access_token(identity=str(user.id)) + refresh_token = create_refresh_token(identity=str(user.id)) + + return ( + jsonify( + { + "user": user.to_dict(), + "access_token": access_token, + "refresh_token": refresh_token, + } + ), + 200, + ) @api_bp.route("/users/me", methods=["GET"]) @jwt_required() def get_current_user(): """Get current user""" - user_id = get_jwt_identity() - user = User.query.get(user_id) - + user_id = int(get_jwt_identity()) + user = db.session.get(User, user_id) + if not user: return jsonify({"error": "User not found"}), 404 - + return jsonify(user.to_dict()), 200 @@ -81,19 +89,20 @@ def get_current_user(): @api_bp.route("/products", methods=["GET"]) def get_products(): """Get all products""" - # time.sleep(5) # This adds a 5 second delay products = Product.query.filter_by(is_active=True).all() - + return jsonify([product.to_dict() for product in products]), 200 @api_bp.route("/products/", methods=["GET"]) def get_product(product_id): """Get a single product""" - product = Product.query.get_or_404(product_id) + product = db.session.get(Product, product_id) + if not product: + return jsonify({"error": "Product not found"}), 404 return jsonify(product.to_dict()), 200 @@ -101,33 +110,33 @@ def get_product(product_id): @jwt_required() def create_product(): """Create a new product (admin only)""" - user_id = get_jwt_identity() - user = User.query.get(user_id) - + user_id = int(get_jwt_identity()) + user = db.session.get(User, user_id) + if not user or not user.is_admin: return jsonify({"error": "Admin access required"}), 403 - + try: # Validate request data using Pydantic schema product_data = ProductCreateRequest(**request.get_json()) - + product = Product( name=product_data.name, description=product_data.description, price=product_data.price, stock=product_data.stock, image_url=product_data.image_url, - category=product_data.category ) - + db.session.add(product) db.session.commit() - + # Use Pydantic schema for response response = ProductResponse.model_validate(product) return jsonify(response.model_dump()), 201 - + except ValidationError as e: + print(f"Pydantic Validation Error: {e.errors()}") return jsonify({"error": "Validation error", "details": e.errors()}), 400 @@ -135,25 +144,27 @@ def create_product(): @jwt_required() def update_product(product_id): """Update a product (admin only)""" - user_id = get_jwt_identity() - user = User.query.get(user_id) - + user_id = int(get_jwt_identity()) + user = db.session.get(User, user_id) + if not user or not user.is_admin: return jsonify({"error": "Admin access required"}), 403 - - product = Product.query.get_or_404(product_id) + + product = db.session.get(Product, product_id) + if not product: + return jsonify({"error": "Product not found"}), 404 + data = request.get_json() - + product.name = data.get("name", product.name) product.description = data.get("description", product.description) product.price = data.get("price", product.price) product.stock = data.get("stock", product.stock) product.image_url = data.get("image_url", product.image_url) - product.category = data.get("category", product.category) product.is_active = data.get("is_active", product.is_active) - + db.session.commit() - + return jsonify(product.to_dict()), 200 @@ -161,16 +172,19 @@ def update_product(product_id): @jwt_required() def delete_product(product_id): """Delete a product (admin only)""" - user_id = get_jwt_identity() - user = User.query.get(user_id) - + user_id = int(get_jwt_identity()) + user = db.session.get(User, user_id) + if not user or not user.is_admin: return jsonify({"error": "Admin access required"}), 403 - - product = Product.query.get_or_404(product_id) + + product = db.session.get(Product, product_id) + if not product: + return jsonify({"error": "Product not found"}), 404 + db.session.delete(product) db.session.commit() - + return jsonify({"message": "Product deleted"}), 200 @@ -179,7 +193,7 @@ def delete_product(product_id): @jwt_required() def get_orders(): """Get all orders for current user""" - user_id = get_jwt_identity() + user_id = int(get_jwt_identity()) orders = Order.query.filter_by(user_id=user_id).all() return jsonify([order.to_dict() for order in orders]), 200 @@ -188,52 +202,56 @@ def get_orders(): @jwt_required() def create_order(): """Create a new order""" - user_id = get_jwt_identity() + user_id = int(get_jwt_identity()) data = request.get_json() - + if not data or not data.get("items"): return jsonify({"error": "Order items are required"}), 400 - + total_amount = 0 order_items = [] - + for item_data in data["items"]: - product = Product.query.get(item_data["product_id"]) + product = db.session.get(Product, item_data["product_id"]) if not product: - return jsonify({"error": f'Product {item_data["product_id"]} not found'}), 404 + return ( + jsonify({"error": f'Product {item_data["product_id"]} not found'}), + 404, + ) if product.stock < item_data["quantity"]: - return jsonify({"error": f'Insufficient stock for {product.name}'}), 400 - + return jsonify({"error": f"Insufficient stock for {product.name}"}), 400 + item_total = product.price * item_data["quantity"] total_amount += item_total - order_items.append({ - "product": product, - "quantity": item_data["quantity"], - "price": product.price - }) - + order_items.append( + { + "product": product, + "quantity": item_data["quantity"], + "price": product.price, + } + ) + order = Order( user_id=user_id, total_amount=total_amount, shipping_address=data.get("shipping_address"), - notes=data.get("notes") ) - + db.session.add(order) db.session.flush() - + for item_data in order_items: order_item = OrderItem( order_id=order.id, product_id=item_data["product"].id, quantity=item_data["quantity"], - price=item_data["price"] + price=item_data["price"], ) item_data["product"].stock -= item_data["quantity"] db.session.add(order_item) - + db.session.commit() - + return jsonify(order.to_dict()), 201 @@ -241,14 +259,16 @@ def create_order(): @jwt_required() def get_order(order_id): """Get a single order""" - user_id = get_jwt_identity() - order = Order.query.get_or_404(order_id) - + user_id = int(get_jwt_identity()) + order = db.session.get(Order, order_id) + if not order: + return jsonify({"error": "Order not found"}), 404 + if order.user_id != user_id: - user = User.query.get(user_id) + user = db.session.get(User, user_id) if not user or not user.is_admin: return jsonify({"error": "Access denied"}), 403 - + return jsonify(order.to_dict()), 200 @@ -259,14 +279,15 @@ def trigger_hello_task(): """Trigger the hello task""" data = request.get_json() or {} name = data.get("name", "World") - + task = celery.send_task("tasks.print_hello", args=[name]) - - return jsonify({ - "message": "Hello task triggered", - "task_id": task.id, - "status": "pending" - }), 202 + + return ( + jsonify( + {"message": "Hello task triggered", "task_id": task.id, "status": "pending"} + ), + 202, + ) @api_bp.route("/tasks/divide", methods=["POST"]) @@ -276,15 +297,20 @@ def trigger_divide_task(): data = request.get_json() or {} x = data.get("x", 10) y = data.get("y", 2) - + task = celery.send_task("tasks.divide_numbers", args=[x, y]) - - return jsonify({ - "message": "Divide task triggered", - "task_id": task.id, - "operation": f"{x} / {y}", - "status": "pending" - }), 202 + + return ( + jsonify( + { + "message": "Divide task triggered", + "task_id": task.id, + "operation": f"{x} / {y}", + "status": "pending", + } + ), + 202, + ) @api_bp.route("/tasks/report", methods=["POST"]) @@ -292,12 +318,17 @@ def trigger_divide_task(): def trigger_report_task(): """Trigger the daily report task""" task = celery.send_task("tasks.send_daily_report") - - return jsonify({ - "message": "Daily report task triggered", - "task_id": task.id, - "status": "pending" - }), 202 + + return ( + jsonify( + { + "message": "Daily report task triggered", + "task_id": task.id, + "status": "pending", + } + ), + 202, + ) @api_bp.route("/tasks/stats", methods=["POST"]) @@ -306,19 +337,15 @@ def trigger_stats_task(): """Trigger product statistics update task""" data = request.get_json() or {} product_id = data.get("product_id") - + if product_id: task = celery.send_task("tasks.update_product_statistics", args=[product_id]) message = f"Product statistics update triggered for product {product_id}" else: task = celery.send_task("tasks.update_product_statistics", args=[None]) message = "Product statistics update triggered for all products" - - return jsonify({ - "message": message, - "task_id": task.id, - "status": "pending" - }), 202 + + return jsonify({"message": message, "task_id": task.id, "status": "pending"}), 202 @api_bp.route("/tasks/long-running", methods=["POST"]) @@ -327,14 +354,19 @@ def trigger_long_running_task(): """Trigger a long-running task""" data = request.get_json() or {} iterations = data.get("iterations", 10) - + task = celery.send_task("tasks.long_running_task", args=[iterations]) - - return jsonify({ - "message": f"Long-running task triggered with {iterations} iterations", - "task_id": task.id, - "status": "pending" - }), 202 + + return ( + jsonify( + { + "message": f"Long-running task triggered with {iterations} iterations", + "task_id": task.id, + "status": "pending", + } + ), + 202, + ) @api_bp.route("/tasks/", methods=["GET"]) @@ -342,20 +374,20 @@ def trigger_long_running_task(): def get_task_status(task_id): """Get the status of a Celery task""" task_result = celery.AsyncResult(task_id) - + response = { "task_id": task_id, "status": task_result.status, - "ready": task_result.ready() + "ready": task_result.ready(), } - + if task_result.ready(): if task_result.successful(): response["result"] = task_result.result else: response["error"] = str(task_result.result) response["traceback"] = task_result.traceback - + return jsonify(response), 200 @@ -366,20 +398,18 @@ def celery_health(): # Try to ping the worker inspector = celery.control.inspect() stats = inspector.stats() - + if stats: - return jsonify({ - "status": "healthy", - "workers": len(stats), - "workers_info": stats - }), 200 + return ( + jsonify( + {"status": "healthy", "workers": len(stats), "workers_info": stats} + ), + 200, + ) else: - return jsonify({ - "status": "unhealthy", - "message": "No workers available" - }), 503 + return ( + jsonify({"status": "unhealthy", "message": "No workers available"}), + 503, + ) except Exception as e: - return jsonify({ - "status": "error", - "message": str(e) - }), 500 + return jsonify({"status": "error", "message": str(e)}), 500 diff --git a/backend/app/routes/health.py b/backend/app/routes/health.py index c46db76..9c24337 100644 --- a/backend/app/routes/health.py +++ b/backend/app/routes/health.py @@ -1,22 +1,16 @@ from flask import Blueprint, jsonify -health_bp = Blueprint('health', __name__) +health_bp = Blueprint("health", __name__) -@health_bp.route('/', methods=['GET']) +@health_bp.route("/", methods=["GET"]) def health_check(): """Health check endpoint""" - return jsonify({ - 'status': 'healthy', - 'service': 'crafting-shop-backend' - }), 200 + return jsonify({"status": "healthy", "service": "crafting-shop-backend"}), 200 -@health_bp.route('/readiness', methods=['GET']) +@health_bp.route("/readiness", methods=["GET"]) def readiness_check(): """Readiness check endpoint""" # Add database check here if needed - return jsonify({ - 'status': 'ready', - 'service': 'crafting-shop-backend' - }), 200 \ No newline at end of file + return jsonify({"status": "ready", "service": "crafting-shop-backend"}), 200 diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 58f1ce0..f5aaf47 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -1,4 +1,4 @@ """Pydantic schemas for request/response validation""" from app.schemas.product import ProductCreateRequest, ProductResponse -__all__ = ["ProductCreateRequest", "ProductResponse"] \ No newline at end of file +__all__ = ["ProductCreateRequest", "ProductResponse"] diff --git a/backend/app/schemas/product.py b/backend/app/schemas/product.py index 304659e..7d9d501 100644 --- a/backend/app/schemas/product.py +++ b/backend/app/schemas/product.py @@ -1,31 +1,36 @@ """Pydantic schemas for Product model""" -from pydantic import BaseModel, Field, field_validator -from decimal import Decimal from datetime import datetime +from decimal import Decimal from typing import Optional +from pydantic import BaseModel, ConfigDict, Field, field_validator + class ProductCreateRequest(BaseModel): """Schema for creating a new product""" - name: str = Field(..., min_length=1, max_length=200, description="Product name") - description: Optional[str] = Field(None, description="Product description") - price: Decimal = Field(..., gt=0, description="Product price (must be greater than 0)") - stock: int = Field(default=0, ge=0, description="Product stock quantity") - image_url: Optional[str] = Field(None, max_length=500, description="Product image URL") - category: Optional[str] = Field(None, description="Product category") - - class Config: - json_schema_extra = { + + model_config = ConfigDict( + json_schema_extra={ "example": { "name": "Handcrafted Wooden Bowl", "description": "A beautiful handcrafted bowl made from oak", "price": 45.99, "stock": 10, "image_url": "https://example.com/bowl.jpg", - "category": "Woodwork" } } - + ) + + name: str = Field(..., min_length=1, max_length=200, description="Product name") + description: Optional[str] = Field(None, description="Product description") + price: Decimal = Field( + ..., gt=0, description="Product price (must be greater than 0)" + ) + stock: int = Field(default=0, ge=0, description="Product stock quantity") + image_url: Optional[str] = Field( + None, max_length=500, description="Product image URL" + ) + @field_validator("price") @classmethod def validate_price(cls, v: Decimal) -> Decimal: @@ -37,19 +42,10 @@ class ProductCreateRequest(BaseModel): class ProductResponse(BaseModel): """Schema for product response""" - id: int - name: str - description: Optional[str] = None - price: float - stock: int - image_url: Optional[str] = None - is_active: bool - created_at: Optional[datetime] = None - updated_at: Optional[datetime] = None - - class Config: - from_attributes = True - json_schema_extra = { + + model_config = ConfigDict( + from_attributes=True, + json_schema_extra={ "example": { "id": 1, "name": "Handcrafted Wooden Bowl", @@ -59,6 +55,17 @@ class ProductResponse(BaseModel): "image_url": "https://example.com/bowl.jpg", "is_active": True, "created_at": "2024-01-15T10:30:00", - "updated_at": "2024-01-15T10:30:00" + "updated_at": "2024-01-15T10:30:00", } - } \ No newline at end of file + }, + ) + + id: int + name: str + description: Optional[str] = None + price: float + stock: int + image_url: Optional[str] = None + is_active: bool + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py index 9a3f623..40a4115 100644 --- a/backend/app/services/__init__.py +++ b/backend/app/services/__init__.py @@ -1 +1 @@ -"""Business logic services""" \ No newline at end of file +"""Business logic services""" diff --git a/backend/app/utils/__init__.py b/backend/app/utils/__init__.py index 6d04308..c8b8b5f 100644 --- a/backend/app/utils/__init__.py +++ b/backend/app/utils/__init__.py @@ -1 +1 @@ -"""Utility functions and helpers""" \ No newline at end of file +"""Utility functions and helpers""" diff --git a/backend/config.py b/backend/config.py deleted file mode 100644 index 44d372f..0000000 --- a/backend/config.py +++ /dev/null @@ -1,69 +0,0 @@ -import os -from datetime import timedelta - - -class Config: - """Base configuration""" - SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production' - SQLALCHEMY_TRACK_MODIFICATIONS = False - JWT_SECRET_KEY = os.environ.get('JWT_SECRET_KEY') or 'jwt-secret-key-change-in-production' - JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=1) - JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=30) - - # Celery Configuration - CELERY = { - "broker_url": os.environ.get("CELERY_BROKER_URL", "redis://localhost:6379/0"), - "result_backend": os.environ.get("CELERY_RESULT_BACKEND", "redis://localhost:6379/0"), - "task_serializer": "json", - "result_serializer": "json", - "accept_content": ["json"], - "timezone": "UTC", - "enable_utc": True, - "task_track_started": True, - "task_time_limit": 30 * 60, # 30 minutes - "task_soft_time_limit": 25 * 60, # 25 minutes - "task_acks_late": True, # Acknowledge after task completion - "task_reject_on_worker_lost": True, # Re-queue if worker dies - "worker_prefetch_multiplier": 1, # Process one task at a time - "worker_max_tasks_per_child": 100, # Restart worker after 100 tasks - "broker_connection_retry_on_startup": True, - "broker_connection_max_retries": 5, - "result_expires": 3600, # Results expire in 1 hour - "task_default_queue": "default", - "task_default_exchange": "default", - "task_default_routing_key": "default", - } - - -class DevelopmentConfig(Config): - """Development configuration""" - DEBUG = True - SQLALCHEMY_DATABASE_URI = os.environ.get('DEV_DATABASE_URL') or \ - 'sqlite:///dev.db' - - -class TestingConfig(Config): - """Testing configuration""" - TESTING = True - SQLALCHEMY_DATABASE_URI = os.environ.get('TEST_DATABASE_URL') or \ - 'sqlite:///test.db' - WTF_CSRF_ENABLED = False - - -class ProductionConfig(Config): - """Production configuration""" - DEBUG = False - SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \ - 'postgresql://user:password@localhost/proddb' - - # Security headers - SESSION_COOKIE_SECURE = True - SESSION_COOKIE_HTTPONLY = True - SESSION_COOKIE_SAMESITE = 'Lax' - - -config_by_name = { - 'dev': DevelopmentConfig, - 'test': TestingConfig, - 'prod': ProductionConfig -} \ No newline at end of file diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 0000000..4047200 --- /dev/null +++ b/backend/pytest.ini @@ -0,0 +1,19 @@ +[pytest] +python_files = test_*.py +python_classes = Test* +python_functions = test_* +testpaths = tests +addopts = + -v + --strict-markers + --tb=short + --cov=app + --cov-report=term-missing + --cov-report=html +markers = + slow: Tests that are slow to run + integration: Integration tests + unit: Unit tests + auth: Authentication tests + product: Product-related tests + order: Order-related tests \ No newline at end of file diff --git a/backend/requirements/base.txt b/backend/requirements/base.txt index c60420f..306f23c 100644 --- a/backend/requirements/base.txt +++ b/backend/requirements/base.txt @@ -9,3 +9,9 @@ Werkzeug==3.0.1 SQLAlchemy==2.0.23 celery[redis]==5.3.6 pydantic==2.5.3 +pytest==7.4.3 +pytest-flask==1.3.0 +pytest-cov==4.1.0 +pytest-mock==3.12.0 +factory-boy==3.3.0 +faker==20.1.0 diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..542eaf5 --- /dev/null +++ b/backend/tests/__init__.py @@ -0,0 +1 @@ +"""Tests package for Flask application""" diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..ee35f04 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,190 @@ +"""Pytest configuration and fixtures""" +import os +import tempfile + +import pytest +from faker import Faker + +from app import create_app, db +from app.models import Order, OrderItem, Product, User + +fake = Faker() + + +@pytest.fixture(scope="function") +def app(): + """Create application for testing with isolated database""" + db_fd, db_path = tempfile.mkstemp() + + app = create_app(config_name="test") + app.config.update( + { + "TESTING": True, + "SQLALCHEMY_DATABASE_URI": f"sqlite:///{db_path}", + "WTF_CSRF_ENABLED": False, + "JWT_SECRET_KEY": "test-secret-keytest-secret-keytest-secret-keytest-secret-keytest-secret-key", + "SERVER_NAME": "localhost.localdomain", + } + ) + + with app.app_context(): + db.create_all() + yield app + db.session.remove() + db.drop_all() + + os.close(db_fd) + os.unlink(db_path) + + +@pytest.fixture +def client(app): + """Test client for making requests""" + return app.test_client() + + +@pytest.fixture +def runner(app): + """Test CLI runner""" + return app.test_cli_runner() + + +@pytest.fixture +def db_session(app): + """Database session for tests""" + with app.app_context(): + yield db.session + + +@pytest.fixture +def admin_user(db_session): + """Create an admin user for testing""" + user = User( + email=fake.email(), + username=fake.user_name(), + first_name=fake.first_name(), + last_name=fake.last_name(), + is_admin=True, + is_active=True, + ) + user.set_password("password123") + db_session.add(user) + db_session.commit() + return user + + +@pytest.fixture +def regular_user(db_session): + """Create a regular user for testing""" + user = User( + email=fake.email(), + username=fake.user_name(), + first_name=fake.first_name(), + last_name=fake.last_name(), + is_admin=False, + is_active=True, + ) + user.set_password("password123") + db_session.add(user) + db_session.commit() + return user + + +@pytest.fixture +def inactive_user(db_session): + """Create an inactive user for testing""" + user = User( + email=fake.email(), + username=fake.user_name(), + first_name=fake.first_name(), + last_name=fake.last_name(), + is_admin=False, + is_active=False, + ) + user.set_password("password123") + db_session.add(user) + db_session.commit() + return user + + +@pytest.fixture +def product(db_session): + """Create a product for testing""" + product = Product( + name=fake.sentence(nb_words=4)[:-1], # Remove period + description=fake.paragraph(), + price=fake.pydecimal(left_digits=2, right_digits=2, positive=True), + stock=fake.pyint(min_value=0, max_value=100), + image_url=fake.url(), + ) + db_session.add(product) + db_session.commit() + return product + + +@pytest.fixture +def products(db_session): + """Create multiple products for testing""" + products = [] + for _ in range(5): + product = Product( + name=fake.sentence(nb_words=4)[:-1], + description=fake.paragraph(), + price=fake.pydecimal(left_digits=2, right_digits=2, positive=True), + stock=fake.pyint(min_value=20, max_value=100), + image_url=fake.url(), + ) + db_session.add(product) + products.append(product) + db_session.commit() + return products + + +@pytest.fixture +def auth_headers(client, regular_user): + """Get authentication headers for a regular user""" + response = client.post( + "/api/auth/login", json={"email": regular_user.email, "password": "password123"} + ) + data = response.get_json() + token = data["access_token"] + print(f"Auth headers token for user {regular_user.email}: {token[:50]}...") + return {"Authorization": f"Bearer {token}"} + + +@pytest.fixture +def admin_headers(client, admin_user): + """Get authentication headers for an admin user""" + response = client.post( + "/api/auth/login", json={"email": admin_user.email, "password": "password123"} + ) + data = response.get_json() + token = data["access_token"] + print(f"Admin headers token for user {admin_user.email}: {token[:50]}...") + return {"Authorization": f"Bearer {token}"} + + +@pytest.fixture +def order(db_session, regular_user, products): + """Create an order for testing""" + order = Order( + user_id=regular_user.id, total_amount=0.0, shipping_address=fake.address() + ) + db_session.add(order) + db_session.flush() + + total_amount = 0 + for i, product in enumerate(products[:2]): + quantity = fake.pyint(min_value=1, max_value=5) + order_item = OrderItem( + order_id=order.id, + product_id=product.id, + quantity=quantity, + price=product.price, + ) + total_amount += float(product.price) * quantity + db_session.add(order_item) + + order.total_amount = total_amount + db_session.commit() + return order diff --git a/backend/tests/test_models.py b/backend/tests/test_models.py new file mode 100644 index 0000000..d92bb3f --- /dev/null +++ b/backend/tests/test_models.py @@ -0,0 +1,200 @@ +"""Test models""" +from decimal import Decimal + +import pytest + +from app.models import Order, OrderItem, Product, User + + +class TestUserModel: + """Test User model""" + + @pytest.mark.unit + def test_user_creation(self, db_session): + """Test creating a user""" + user = User( + email="test@example.com", + username="testuser", + first_name="Test", + last_name="User", + is_admin=False, + is_active=True, + ) + user.set_password("password123") + db_session.add(user) + db_session.commit() + + assert user.id is not None + assert user.email == "test@example.com" + assert user.username == "testuser" + assert user.first_name == "Test" + assert user.last_name == "User" + + @pytest.mark.unit + def test_user_password_hashing(self, db_session): + """Test password hashing and verification""" + user = User(email="test@example.com", username="testuser") + user.set_password("password123") + db_session.add(user) + db_session.commit() + + assert user.check_password("password123") is True + assert user.check_password("wrongpassword") is False + + @pytest.mark.unit + def test_user_to_dict(self, db_session): + """Test user serialization to dictionary""" + user = User( + email="test@example.com", + username="testuser", + first_name="Test", + last_name="User", + ) + user.set_password("password123") + db_session.add(user) + db_session.commit() + + user_dict = user.to_dict() + assert user_dict["email"] == "test@example.com" + assert user_dict["username"] == "testuser" + assert "password" not in user_dict + assert "password_hash" not in user_dict + + @pytest.mark.unit + def test_user_repr(self, db_session): + """Test user string representation""" + user = User(email="test@example.com", username="testuser") + user.set_password("password123") + db_session.add(user) + db_session.commit() + + assert repr(user) == "" + + +class TestProductModel: + """Test Product model""" + + @pytest.mark.unit + def test_product_creation(self, db_session): + """Test creating a product""" + product = Product( + name="Test Product", + description="A test product", + price=Decimal("99.99"), + stock=10, + image_url="https://example.com/product.jpg", + ) + db_session.add(product) + db_session.commit() + + assert product.id is not None + assert product.name == "Test Product" + assert product.price == Decimal("99.99") + assert product.stock == 10 + assert product.is_active is True + + @pytest.mark.unit + def test_product_to_dict(self, db_session): + """Test product serialization to dictionary""" + product = Product( + name="Test Product", + description="A test product", + price=Decimal("99.99"), + stock=10, + ) + db_session.add(product) + db_session.commit() + + product_dict = product.to_dict() + assert product_dict["name"] == "Test Product" + assert product_dict["price"] == 99.99 + assert isinstance(product_dict["created_at"], str) + assert isinstance(product_dict["updated_at"], str) + + @pytest.mark.unit + def test_product_defaults(self, db_session): + """Test product default values""" + product = Product(name="Test Product", price=Decimal("9.99")) + db_session.add(product) + db_session.commit() + + assert product.stock == 0 + assert product.is_active is True + assert product.description is None + assert product.image_url is None + + @pytest.mark.unit + def test_product_repr(self, db_session): + """Test product string representation""" + product = Product(name="Test Product", price=Decimal("9.99")) + db_session.add(product) + db_session.commit() + + assert repr(product) == "" + + +class TestOrderModel: + """Test Order model""" + + @pytest.mark.unit + def test_order_creation(self, db_session, regular_user): + """Test creating an order""" + order = Order( + user_id=regular_user.id, + total_amount=Decimal("199.99"), + shipping_address="123 Test St", + ) + db_session.add(order) + db_session.commit() + + assert order.id is not None + assert order.user_id == regular_user.id + assert order.total_amount == Decimal("199.99") + + @pytest.mark.unit + def test_order_to_dict(self, db_session, regular_user): + """Test order serialization to dictionary""" + order = Order( + user_id=regular_user.id, + total_amount=Decimal("199.99"), + shipping_address="123 Test St", + ) + db_session.add(order) + db_session.commit() + + order_dict = order.to_dict() + assert order_dict["user_id"] == regular_user.id + assert order_dict["total_amount"] == 199.99 + assert isinstance(order_dict["created_at"], str) + + +class TestOrderItemModel: + """Test OrderItem model""" + + @pytest.mark.unit + def test_order_item_creation(self, db_session, order, product): + """Test creating an order item""" + order_item = OrderItem( + order_id=order.id, product_id=product.id, quantity=2, price=product.price + ) + db_session.add(order_item) + db_session.commit() + + assert order_item.id is not None + assert order_item.order_id == order.id + assert order_item.product_id == product.id + assert order_item.quantity == 2 + + @pytest.mark.unit + def test_order_item_to_dict(self, db_session, order, product): + """Test order item serialization to dictionary""" + order_item = OrderItem( + order_id=order.id, product_id=product.id, quantity=2, price=product.price + ) + db_session.add(order_item) + db_session.commit() + + item_dict = order_item.to_dict() + assert item_dict["order_id"] == order.id + assert item_dict["product_id"] == product.id + assert item_dict["quantity"] == 2 diff --git a/backend/tests/test_routes.py b/backend/tests/test_routes.py new file mode 100644 index 0000000..7685764 --- /dev/null +++ b/backend/tests/test_routes.py @@ -0,0 +1,339 @@ +"""Test API routes""" +import pytest + + +class TestAuthRoutes: + """Test authentication routes""" + + @pytest.mark.auth + def test_register_success(self, client): + """Test successful user registration""" + response = client.post( + "/api/auth/register", + json={ + "email": "newuser@example.com", + "password": "password123", + "username": "newuser", + "first_name": "New", + "last_name": "User", + }, + ) + + assert response.status_code == 201 + data = response.get_json() + assert data["email"] == "newuser@example.com" + assert data["username"] == "newuser" + assert "password" not in data + assert "password_hash" not in data + + @pytest.mark.auth + def test_register_missing_fields(self, client): + """Test registration with missing required fields""" + response = client.post( + "/api/auth/register", json={"email": "newuser@example.com"} + ) + + assert response.status_code == 400 + data = response.get_json() + assert "error" in data + + @pytest.mark.auth + def test_register_duplicate_email(self, client, regular_user): + """Test registration with duplicate email""" + response = client.post( + "/api/auth/register", + json={"email": regular_user.email, "password": "password123"}, + ) + + assert response.status_code == 400 + data = response.get_json() + assert "already exists" in data["error"].lower() + + @pytest.mark.auth + def test_login_success(self, client, regular_user): + """Test successful login""" + response = client.post( + "/api/auth/login", + json={"email": regular_user.email, "password": "password123"}, + ) + + assert response.status_code == 200 + data = response.get_json() + assert "access_token" in data + assert "refresh_token" in data + assert data["user"]["email"] == regular_user.email + + @pytest.mark.auth + @pytest.mark.parametrize( + "email,password,expected_status", + [ + ("wrong@example.com", "password123", 401), + ("user@example.com", "wrongpassword", 401), + (None, "password123", 400), + ("user@example.com", None, 400), + ], + ) + def test_login_validation( + self, client, regular_user, email, password, expected_status + ): + """Test login with various invalid inputs""" + login_data = {} + if email is not None: + login_data["email"] = email + if password is not None: + login_data["password"] = password + + response = client.post("/api/auth/login", json=login_data) + assert response.status_code == expected_status + + @pytest.mark.auth + def test_login_inactive_user(self, client, inactive_user): + """Test login with inactive user""" + response = client.post( + "/api/auth/login", + json={"email": inactive_user.email, "password": "password123"}, + ) + + assert response.status_code == 401 + data = response.get_json() + assert "inactive" in data["error"].lower() + + @pytest.mark.auth + def test_get_current_user(self, client, auth_headers, regular_user): + """Test getting current user""" + response = client.get("/api/users/me", headers=auth_headers) + + assert response.status_code == 200 + data = response.get_json() + assert data["email"] == regular_user.email + + @pytest.mark.auth + def test_get_current_user_unauthorized(self, client): + """Test getting current user without authentication""" + response = client.get("/api/users/me") + assert response.status_code == 401 + + +class TestProductRoutes: + """Test product routes""" + + @pytest.mark.product + def test_get_products(self, client, products): + """Test getting all products""" + response = client.get("/api/products") + + assert response.status_code == 200 + data = response.get_json() + assert len(data) == 5 + + @pytest.mark.product + def test_get_products_empty(self, client): + """Test getting products when none exist""" + response = client.get("/api/products") + + assert response.status_code == 200 + data = response.get_json() + assert len(data) == 0 + + @pytest.mark.product + def test_get_single_product(self, client, product): + """Test getting a single product""" + response = client.get(f"/api/products/{product.id}") + + assert response.status_code == 200 + data = response.get_json() + assert data["id"] == product.id + assert data["name"] == product.name + + @pytest.mark.product + def test_get_product_not_found(self, client): + """Test getting non-existent product""" + response = client.get("/api/products/999") + assert response.status_code == 404 + + @pytest.mark.product + def test_create_product_admin(self, client, admin_headers): + """Test creating product as admin""" + response = client.post( + "/api/products", + headers=admin_headers, + json={ + "name": "New Product", + "description": "A new product", + "price": 29.99, + "stock": 10, + }, + ) + + assert response.status_code == 201 + data = response.get_json() + assert data["name"] == "New Product" + assert data["price"] == 29.99 + + @pytest.mark.product + def test_create_product_regular_user(self, client, auth_headers): + """Test creating product as regular user (should fail)""" + response = client.post( + "/api/products", + headers=auth_headers, + json={"name": "New Product", "price": 29.99}, + ) + + assert response.status_code == 403 + data = response.get_json() + assert "admin" in data["error"].lower() + + @pytest.mark.product + def test_create_product_unauthorized(self, client): + """Test creating product without authentication""" + response = client.post( + "/api/products", json={"name": "New Product", "price": 29.99} + ) + + assert response.status_code == 401 + + @pytest.mark.product + def test_create_product_validation_error(self, client, admin_headers): + """Test creating product with invalid data""" + response = client.post( + "/api/products", + headers=admin_headers, + json={"name": "New Product", "price": -10.99}, + ) + + assert response.status_code == 400 + data = response.get_json() + assert "Validation error" in data["error"] + + @pytest.mark.product + def test_create_product_missing_required_fields(self, client, admin_headers): + """Test creating product with missing required fields""" + response = client.post( + "/api/products", + headers=admin_headers, + json={"description": "Missing name and price"}, + ) + + assert response.status_code == 400 + data = response.get_json() + assert "Validation error" in data["error"] + + @pytest.mark.product + def test_create_product_minimal_data(self, client, admin_headers): + """Test creating product with minimal valid data""" + response = client.post( + "/api/products", + headers=admin_headers, + json={"name": "Minimal Product", "price": 19.99}, + ) + + assert response.status_code == 201 + data = response.get_json() + assert data["name"] == "Minimal Product" + assert data["stock"] == 0 # Default value + + @pytest.mark.product + def test_update_product_admin(self, client, admin_headers, product): + """Test updating product as admin""" + response = client.put( + f"/api/products/{product.id}", + headers=admin_headers, + json={"name": "Updated Product", "price": 39.99}, + ) + + assert response.status_code == 200 + data = response.get_json() + assert data["name"] == "Updated Product" + assert data["price"] == 39.99 + + @pytest.mark.product + def test_delete_product_admin(self, client, admin_headers, product): + """Test deleting product as admin""" + response = client.delete(f"/api/products/{product.id}", headers=admin_headers) + assert response.status_code == 200 + + # Verify product is deleted + response = client.get(f"/api/products/{product.id}") + assert response.status_code == 404 + + +class TestOrderRoutes: + """Test order routes""" + + @pytest.mark.order + def test_get_orders(self, client, auth_headers, order): + """Test getting orders for current user""" + response = client.get("/api/orders", headers=auth_headers) + + assert response.status_code == 200 + data = response.get_json() + assert len(data) >= 1 + + @pytest.mark.order + def test_get_orders_unauthorized(self, client): + """Test getting orders without authentication""" + response = client.get("/api/orders") + assert response.status_code == 401 + + @pytest.mark.order + def test_create_order(self, client, auth_headers, products): + """Test creating an order""" + response = client.post( + "/api/orders", + headers=auth_headers, + json={ + "items": [ + {"product_id": products[0].id, "quantity": 2}, + {"product_id": products[1].id, "quantity": 1}, + ], + "shipping_address": "123 Test St", + }, + ) + + assert response.status_code == 201 + data = response.get_json() + assert "id" in data + assert len(data["items"]) == 2 + + @pytest.mark.order + def test_create_order_insufficient_stock( + self, client, auth_headers, db_session, products + ): + """Test creating order with insufficient stock""" + # Set stock to 0 + products[0].stock = 0 + db_session.commit() + + response = client.post( + "/api/orders", + headers=auth_headers, + json={"items": [{"product_id": products[0].id, "quantity": 2}]}, + ) + + assert response.status_code == 400 + data = response.get_json() + assert "insufficient" in data["error"].lower() + + @pytest.mark.order + def test_get_single_order(self, client, auth_headers, order): + """Test getting a single order""" + response = client.get(f"/api/orders/{order.id}", headers=auth_headers) + + print("test_get_single_order", response.get_json()) + + assert response.status_code == 200 + data = response.get_json() + assert data["id"] == order.id + + @pytest.mark.order + def test_get_other_users_order(self, client, admin_headers, regular_user, products): + """Test admin accessing another user's order""" + # Create an order for regular_user + client.post( + "/api/auth/login", + json={"email": regular_user.email, "password": "password123"}, + ) + + # Admin should be able to access any order + # This test assumes order exists, adjust as needed + pass diff --git a/backend/tests/test_schemas.py b/backend/tests/test_schemas.py new file mode 100644 index 0000000..0fa7a90 --- /dev/null +++ b/backend/tests/test_schemas.py @@ -0,0 +1,249 @@ +"""Test Pydantic schemas""" +from decimal import Decimal + +import pytest +from pydantic import ValidationError + +from app.schemas import ProductCreateRequest, ProductResponse + + +class TestProductCreateRequestSchema: + """Test ProductCreateRequest schema""" + + @pytest.mark.unit + def test_valid_product_request(self): + """Test valid product creation request""" + data = { + "name": "Handcrafted Wooden Bowl", + "description": "A beautiful handcrafted bowl", + "price": 45.99, + "stock": 10, + "image_url": "https://example.com/bowl.jpg", + } + + product = ProductCreateRequest(**data) + assert product.name == data["name"] + assert product.description == data["description"] + assert product.price == Decimal("45.99") + assert product.stock == 10 + assert product.image_url == data["image_url"] + + @pytest.mark.unit + def test_minimal_valid_request(self): + """Test minimal valid request (only required fields)""" + data = {"name": "Simple Product", "price": 19.99} + + product = ProductCreateRequest(**data) + assert product.name == "Simple Product" + assert product.price == Decimal("19.99") + assert product.stock == 0 + assert product.description is None + assert product.image_url is None + + @pytest.mark.unit + def test_missing_name(self): + """Test request with missing name""" + data = {"price": 19.99} + + with pytest.raises(ValidationError) as exc_info: + ProductCreateRequest(**data) + + errors = exc_info.value.errors() + assert any(error["loc"] == ("name",) for error in errors) + + @pytest.mark.unit + def test_missing_price(self): + """Test request with missing price""" + data = {"name": "Test Product"} + + with pytest.raises(ValidationError) as exc_info: + ProductCreateRequest(**data) + + errors = exc_info.value.errors() + assert any(error["loc"] == ("price",) for error in errors) + + @pytest.mark.unit + def test_invalid_price_negative(self): + """Test request with negative price""" + data = {"name": "Test Product", "price": -10.99} + + with pytest.raises(ValidationError) as exc_info: + ProductCreateRequest(**data) + + errors = exc_info.value.errors() + assert any(error["type"] == "greater_than" for error in errors) + + @pytest.mark.unit + def test_invalid_price_zero(self): + """Test request with zero price""" + data = {"name": "Test Product", "price": 0.0} + + with pytest.raises(ValidationError) as exc_info: + ProductCreateRequest(**data) + + errors = exc_info.value.errors() + assert any(error["type"] == "greater_than" for error in errors) + + @pytest.mark.unit + def test_invalid_price_too_many_decimals(self): + """Test request with too many decimal places""" + data = {"name": "Test Product", "price": 10.999} + + with pytest.raises(ValidationError) as exc_info: + ProductCreateRequest(**data) + + errors = exc_info.value.errors() + assert any("decimal places" in str(error).lower() for error in errors) + + @pytest.mark.unit + def test_invalid_stock_negative(self): + """Test request with negative stock""" + data = {"name": "Test Product", "price": 19.99, "stock": -5} + + with pytest.raises(ValidationError) as exc_info: + ProductCreateRequest(**data) + + errors = exc_info.value.errors() + assert any(error["type"] == "greater_than_equal" for error in errors) + + @pytest.mark.unit + def test_name_too_long(self): + """Test request with name exceeding max length""" + data = {"name": "A" * 201, "price": 19.99} # Exceeds 200 character limit + + with pytest.raises(ValidationError) as exc_info: + ProductCreateRequest(**data) + + errors = exc_info.value.errors() + assert any(error["loc"] == ("name",) for error in errors) + + @pytest.mark.unit + def test_image_url_too_long(self): + """Test request with image_url exceeding max length""" + data = { + "name": "Test Product", + "price": 19.99, + "image_url": "A" * 501, # Exceeds 500 character limit + } + + with pytest.raises(ValidationError) as exc_info: + ProductCreateRequest(**data) + + errors = exc_info.value.errors() + assert any(error["loc"] == ("image_url",) for error in errors) + + @pytest.mark.unit + def test_price_string_conversion(self): + """Test price string to Decimal conversion""" + data = {"name": "Test Product", "price": "29.99"} + + product = ProductCreateRequest(**data) + assert product.price == Decimal("29.99") + + @pytest.mark.unit + def test_stock_string_conversion(self): + """Test stock string to int conversion""" + data = {"name": "Test Product", "price": 19.99, "stock": "10"} + + product = ProductCreateRequest(**data) + assert product.stock == 10 + assert isinstance(product.stock, int) + + +class TestProductResponseSchema: + """Test ProductResponse schema""" + + @pytest.mark.unit + def test_valid_product_response(self): + """Test valid product response""" + data = { + "id": 1, + "name": "Test Product", + "description": "A test product", + "price": 45.99, + "stock": 10, + "image_url": "https://example.com/product.jpg", + "is_active": True, + "created_at": "2024-01-15T10:30:00", + "updated_at": "2024-01-15T10:30:00", + } + + product = ProductResponse(**data) + assert product.id == 1 + assert product.name == "Test Product" + assert product.price == 45.99 + assert product.stock == 10 + assert product.is_active is True + + @pytest.mark.unit + def test_product_response_with_none_fields(self): + """Test product response with optional None fields""" + data = { + "id": 1, + "name": "Test Product", + "price": 19.99, + "stock": 0, + "is_active": True, + } + + product = ProductResponse(**data) + assert product.description is None + assert product.image_url is None + assert product.created_at is None + assert product.updated_at is None + + @pytest.mark.unit + def test_model_validate_from_sqlalchemy(self, db_session): + """Test validating SQLAlchemy model to Pydantic schema""" + from app.models import Product + + db_product = Product( + name="Test Product", + description="A test product", + price=Decimal("45.99"), + stock=10, + ) + db_session.add(db_product) + db_session.commit() + + # Validate using model_validate (for SQLAlchemy models) + response = ProductResponse.model_validate(db_product) + assert response.name == "Test Product" + assert response.price == 45.99 + assert response.stock == 10 + + @pytest.mark.unit + def test_model_dump(self): + """Test model_dump method""" + data = { + "id": 1, + "name": "Test Product", + "price": 19.99, + "stock": 5, + "is_active": True, + } + + product = ProductResponse(**data) + dumped = product.model_dump() + + assert isinstance(dumped, dict) + assert dumped["id"] == 1 + assert dumped["name"] == "Test Product" + assert dumped["price"] == 19.99 + + @pytest.mark.unit + def test_model_dump_json(self): + """Test model_dump_json method""" + data = { + "id": 1, + "name": "Test Product", + "price": 19.99, + "stock": 5, + "is_active": True, + } + + product = ProductResponse(**data) + json_str = product.model_dump_json() + + assert isinstance(json_str, str) + assert "Test Product" in json_str diff --git a/docs/usage_rules_backend.md b/docs/usage_rules_backend.md index 148c152..997fd00 100644 --- a/docs/usage_rules_backend.md +++ b/docs/usage_rules_backend.md @@ -119,6 +119,18 @@ def create_product(): - **ALWAYS** import `db` from `app` - Use `db.session.add()` and `db.session.commit()` for transactions - Use `db.session.flush()` when you need the ID before commit +- **ALWAYS** use `db.session.get(Model, id)` instead of `Model.query.get(id)` (SQLAlchemy 2.0) +- Use `Model.query.get_or_404(id)` for 404 handling when appropriate +```python +# ✅ CORRECT - SQLAlchemy 2.0 syntax +from app import db +from app.models import User + +user = db.session.get(User, user_id) + +# ❌ WRONG - Legacy syntax (deprecated) +user = User.query.get(user_id) +``` ### Error Handling - Handle common errors (404, 400, 401, 403, 500) @@ -189,11 +201,308 @@ def create_product_service(data): - Use pytest framework - Place tests in `backend/tests/` - Use fixtures for common setup +- Organize tests by functionality: `test_models.py`, `test_routes.py`, `test_schemas.py` + +### Test Naming Conventions +- Test files must start with `test_`: `test_products.py`, `test_users.py` +- Test classes must start with `Test`: `TestProductModel`, `TestAuthRoutes` +- Test functions must start with `test_`: `test_create_product`, `test_login_success` +- Use descriptive names: `test_create_product_with_valid_data` (not `test_product`) + +### Writing Tests + +#### Basic Test Structure + +```python +# ✅ CORRECT +import pytest +from app import db +from app.models import Product + +class TestProductModel: + """Test Product model""" + + @pytest.mark.unit + def test_product_creation(self, db_session): + """Test creating a product with valid data""" + product = Product( + name='Test Product', + price=99.99 + ) + db_session.add(product) + db_session.commit() + + assert product.id is not None + assert product.name == 'Test Product' +``` + +#### Testing API Routes + +```python +# ✅ CORRECT +def test_create_product(client, admin_headers): + """Test creating a product as admin""" + response = client.post('/api/products', + headers=admin_headers, + json={ + 'name': 'New Product', + 'price': 29.99 + }) + + assert response.status_code == 201 + data = response.get_json() + assert data['name'] == 'New Product' + assert 'password' not in data +``` + +#### Using Fixtures + +```python +# ✅ CORRECT - Use available fixtures +def test_get_products(client, products): + """Test getting all products""" + response = client.get('/api/products') + + assert response.status_code == 200 + data = response.get_json() + assert len(data) == 5 + +# ❌ WRONG - Don't create fixtures manually in tests +def test_get_products_wrong(client, db_session): + products = [] + for _ in range(5): + p = Product(name='Test', price=10) + db_session.add(p) + products.append(p) + db_session.commit() + # ... use fixtures instead! +``` + +#### Testing Both Success and Failure Cases + +```python +# ✅ CORRECT - Test both scenarios +def test_create_product_success(client, admin_headers): + """Test creating product successfully""" + response = client.post('/api/products', + headers=admin_headers, + json={'name': 'Test', 'price': 10}) + assert response.status_code == 201 + +def test_create_product_unauthorized(client): + """Test creating product without authentication""" + response = client.post('/api/products', + json={'name': 'Test', 'price': 10}) + assert response.status_code == 401 + +def test_create_product_validation_error(client, admin_headers): + """Test creating product with invalid data""" + response = client.post('/api/products', + headers=admin_headers, + json={'name': 'Test', 'price': -10}) + assert response.status_code == 400 +``` + +#### Parameterized Tests + +```python +# ✅ CORRECT - Use parameterization for similar tests +@pytest.mark.parametrize("email,password,expected_status", [ + ("user@example.com", "correct123", 200), + ("wrong@email.com", "correct123", 401), + ("user@example.com", "wrongpass", 401), +]) +def test_login_validation(client, email, password, expected_status): + """Test login with various invalid inputs""" + response = client.post('/api/auth/login', json={ + 'email': email, + 'password': password + }) + assert response.status_code == expected_status +``` + +### Test Markers + +Use appropriate markers for categorizing tests: + +```python +# ✅ CORRECT +@pytest.mark.unit +def test_user_creation(self, db_session): + """Unit test - no HTTP, no external services""" + pass + +@pytest.mark.integration +def test_user_workflow(self, client): + """Integration test - full request/response cycle""" + pass + +@pytest.mark.auth +def test_login(self, client): + """Authentication-related test""" + pass + +@pytest.mark.product +def test_get_products(self, client): + """Product-related test""" + pass +``` ### Database in Tests - Use in-memory SQLite for tests - Clean up database between tests - Use `pytest.fixture` for database setup +- **NEVER** use production database in tests +- **NEVER** share state between tests + +```python +# ✅ CORRECT - Use db_session fixture +def test_something(db_session): + user = User(email='test@example.com') + db_session.add(user) + db_session.commit() + +# ❌ WRONG - Don't access db directly +def test_something_wrong(): + from app import db + user = User(email='test@example.com') + db.session.add(user) + db.session.commit() +``` + +### Available Fixtures + +Use these fixtures from `tests/conftest.py`: + +- **`app`**: Flask application instance with test configuration +- **`client`**: Test client for making HTTP requests +- **`runner`**: CLI runner for Flask commands +- **`db_session`**: Database session for database operations +- **`admin_user`**: Pre-created admin user +- **`regular_user`**: Pre-created regular user +- **`inactive_user`**: Pre-created inactive user +- **`product`**: Single product +- **`products`**: Multiple products (5 items) +- **`auth_headers`**: JWT headers for regular user +- **`admin_headers`**: JWT headers for admin user +- **`order`**: Pre-created order with items + +### Creating Custom Fixtures + +```python +# In tests/conftest.py or test file +@pytest.fixture +def custom_resource(db_session): + """Create a custom test resource""" + resource = CustomModel( + name='Test Resource', + value=100 + ) + db_session.add(resource) + db_session.commit() + return resource + +# Use in tests +def test_custom_fixture(custom_resource): + assert custom_resource.name == 'Test Resource' +``` + +### Running Tests + +```bash +# Run all tests +make test-backend + +# Run with coverage +make test-backend-cov + +# Run with verbose output +make test-backend-verbose + +# Run specific test file +make test-backend-specific TEST=test_models.py + +# Run by marker +make test-backend-marker MARKER=auth + +# Run only failed tests +make test-backend-failed + +# Run in parallel (faster) +make test-backend-parallel +``` + +### Test Coverage Requirements + +- **Minimum 80%** code coverage required +- **Critical paths** (auth, payments, data modification) must have >90% coverage +- All new features must include tests + +```python +# ✅ CORRECT - Comprehensive test coverage +def test_product_crud(self, client, admin_headers): + """Test complete CRUD operations""" + # Create + response = client.post('/api/products', + headers=admin_headers, + json={'name': 'Test', 'price': 10}) + assert response.status_code == 201 + product_id = response.get_json()['id'] + + # Read + response = client.get(f'/api/products/{product_id}') + assert response.status_code == 200 + + # Update + response = client.put(f'/api/products/{product_id}', + headers=admin_headers, + json={'name': 'Updated', 'price': 20}) + assert response.status_code == 200 + + # Delete + response = client.delete(f'/api/products/{product_id}', + headers=admin_headers) + assert response.status_code == 200 +``` + +### Mocking External Services + +```python +# ✅ CORRECT - Mock external dependencies +def test_external_api_call(client, mocker): + """Test endpoint that calls external API""" + mock_response = {'data': 'mocked data'} + + # Mock requests.get + mock_get = mocker.patch('requests.get') + mock_get.return_value.json.return_value = mock_response + mock_get.return_value.status_code = 200 + + response = client.get('/api/external-data') + assert response.status_code == 200 + assert response.get_json() == mock_response + mock_get.assert_called_once() +``` + +### Test DOs and DON'Ts + +✅ **DO:** +- Use descriptive test names +- Test both success and failure cases +- Use fixtures for common setup +- Mock external services +- Keep tests independent +- Use markers appropriately +- Test edge cases and boundary conditions + +❌ **DON'T:** +- Share state between tests +- Hardcode sensitive data (use faker) +- Use production database +- Skip error case testing +- Write tests after deployment +- Ignore slow tests in CI +- Use complex setup in test methods (use fixtures instead) ## Security Rules diff --git a/frontend/.eslintrc.json b/frontend/.eslintrc.json new file mode 100644 index 0000000..657312a --- /dev/null +++ b/frontend/.eslintrc.json @@ -0,0 +1,50 @@ +{ + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaVersion": 2021, + "sourceType": "module", + "ecmaFeatures": { + "jsx": true + } + }, + "extends": [ + "eslint:recommended", + "plugin:react/recommended", + "plugin:react-hooks/recommended", + "plugin:@typescript-eslint/recommended", + "plugin:prettier/recommended" + ], + "plugins": [ + "react", + "react-hooks", + "@typescript-eslint", + "prettier" + ], + "settings": { + "react": { + "version": "detect" + } + }, + "rules": { + "react/react-in-jsx-scope": "off", + "react/prop-types": "off", + "@typescript-eslint/explicit-module-boundary-types": "off", + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-unused-vars": [ + "warn", + { + "argsIgnorePattern": "^_", + "varsIgnorePattern": "^_" + } + ], + "prettier/prettier": "warn" + }, + "ignorePatterns": [ + "dist", + "build", + "node_modules", + "*.config.js", + "*.config.ts", + "vite.config.ts" + ] +} \ No newline at end of file diff --git a/frontend/.prettierignore b/frontend/.prettierignore new file mode 100644 index 0000000..4fea64b --- /dev/null +++ b/frontend/.prettierignore @@ -0,0 +1,27 @@ +# Dependencies +node_modules/ +package-lock.json + +# Build outputs +dist/ +build/ + +# Testing +coverage/ + +# Misc +.DS_Store +*.log +.env +.env.* + +# Config files that might have different formatting +*.config.js +*.config.ts +vite.config.ts +vitest.config.ts +postcss.config.js +tailwind.config.js + +# Other +public/ \ No newline at end of file diff --git a/frontend/.prettierrc.json b/frontend/.prettierrc.json new file mode 100644 index 0000000..d858cde --- /dev/null +++ b/frontend/.prettierrc.json @@ -0,0 +1,10 @@ +{ + "semi": true, + "trailingComma": "es5", + "singleQuote": true, + "printWidth": 100, + "tabWidth": 2, + "useTabs": false, + "arrowParens": "always", + "endOfLine": "lf" +} \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9f3290c..8675e67 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -19,15 +19,20 @@ "@testing-library/user-event": "^14.5.1", "@types/react": "^18.3.28", "@types/react-dom": "^18.3.7", + "@typescript-eslint/eslint-plugin": "^8.56.1", + "@typescript-eslint/parser": "^8.56.1", "@vitejs/plugin-react": "^4.2.1", "@vitest/ui": "^1.0.4", "autoprefixer": "^10.4.16", "eslint": "^8.55.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.5", "eslint-plugin-react": "^7.33.2", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.5", "jsdom": "^23.0.1", "postcss": "^8.4.32", + "prettier": "^3.8.1", "tailwindcss": "^3.4.0", "typescript": "^5.9.3", "vite": "^5.0.8", @@ -1018,6 +1023,18 @@ "node": ">= 8" } }, + "node_modules/@pkgr/core": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", + "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "dev": true, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -1551,6 +1568,285 @@ "@types/react": "^18.0.0" } }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz", + "integrity": "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==", + "dev": true, + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/type-utils": "8.56.1", + "@typescript-eslint/utils": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.56.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.1.tgz", + "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", + "dev": true, + "dependencies": { + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.1.tgz", + "integrity": "sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.56.1", + "@typescript-eslint/types": "^8.56.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.1.tgz", + "integrity": "sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.1.tgz", + "integrity": "sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.1.tgz", + "integrity": "sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/utils": "8.56.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.1.tgz", + "integrity": "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.1.tgz", + "integrity": "sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==", + "dev": true, + "dependencies": { + "@typescript-eslint/project-service": "8.56.1", + "@typescript-eslint/tsconfig-utils": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", + "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", + "dev": true, + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.3.tgz", + "integrity": "sha512-Rwi3pnapEqirPSbWbrZaa6N3nmqq4Xer/2XooiOKyV3q12ML06f7MOuc5DVH8ONZIFhwIYQ3yzPH4nt7iWHaTg==", + "dev": true, + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.1.tgz", + "integrity": "sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.1.tgz", + "integrity": "sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.56.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@ungap/structured-clone": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", @@ -3045,6 +3341,51 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-prettier": { + "version": "5.5.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.5.tgz", + "integrity": "sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==", + "dev": true, + "dependencies": { + "prettier-linter-helpers": "^1.0.1", + "synckit": "^0.11.12" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-prettier" + }, + "peerDependencies": { + "@types/eslint": ">=8.0.0", + "eslint": ">=8.0.0", + "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", + "prettier": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@types/eslint": { + "optional": true + }, + "eslint-config-prettier": { + "optional": true + } + } + }, "node_modules/eslint-plugin-react": { "version": "7.37.5", "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", @@ -3235,6 +3576,12 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "dev": true + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -5293,6 +5640,33 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-linter-helpers": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz", + "integrity": "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==", + "dev": true, + "dependencies": { + "fast-diff": "^1.1.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/pretty-format": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", @@ -6175,6 +6549,21 @@ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", "dev": true }, + "node_modules/synckit": { + "version": "0.11.12", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", + "integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==", + "dev": true, + "dependencies": { + "@pkgr/core": "^0.2.9" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" + } + }, "node_modules/tailwindcss": { "version": "3.4.19", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", @@ -6376,6 +6765,18 @@ "node": ">=18" } }, + "node_modules/ts-api-utils": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "dev": true, + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", diff --git a/frontend/package.json b/frontend/package.json index 0014018..043c425 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,7 +6,10 @@ "dev": "vite", "build": "vite build", "preview": "vite preview", - "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0", + "lint": "eslint . --ext ts,tsx,js,jsx --report-unused-disable-directives --max-warnings 0", + "lint:fix": "eslint . --ext ts,tsx,js,jsx --fix", + "format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"", + "format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"", "test": "vitest", "test:ui": "vitest --ui" }, @@ -22,15 +25,20 @@ "@testing-library/user-event": "^14.5.1", "@types/react": "^18.3.28", "@types/react-dom": "^18.3.7", + "@typescript-eslint/eslint-plugin": "^8.56.1", + "@typescript-eslint/parser": "^8.56.1", "@vitejs/plugin-react": "^4.2.1", "@vitest/ui": "^1.0.4", "autoprefixer": "^10.4.16", "eslint": "^8.55.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.5", "eslint-plugin-react": "^7.33.2", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.5", "jsdom": "^23.0.1", "postcss": "^8.4.32", + "prettier": "^3.8.1", "tailwindcss": "^3.4.0", "typescript": "^5.9.3", "vite": "^5.0.8", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a539c36..57f0f6c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,17 +1,17 @@ -import { Routes, Route } from 'react-router-dom' -import { ModalProvider } from './context/modals/useModal' -import { ModalRoot } from './context/modals/ModalRoot' -import { ToastProvider } from './context/toasts/useToast' -import { ToastRoot } from './context/toasts/ToastRoot' -import { LoaderProvider } from './context/loaders/useLoader' -import { LoaderRoot } from './context/loaders/LoaderRoot' -import Cart from './pages/Cart' -import { Navbar } from './components/Navbar' -import { Home } from './pages/Home' -import { Products } from './pages/Products' -import Login from './pages/Login' -import { Register } from './pages/Register' -import { Orders } from './pages/Orders' +import { Routes, Route } from 'react-router-dom'; +import { ModalProvider } from './context/modals/useModal'; +import { ModalRoot } from './context/modals/ModalRoot'; +import { ToastProvider } from './context/toasts/useToast'; +import { ToastRoot } from './context/toasts/ToastRoot'; +import { LoaderProvider } from './context/loaders/useLoader'; +import { LoaderRoot } from './context/loaders/LoaderRoot'; +import Cart from './pages/Cart'; +import { Navbar } from './components/Navbar'; +import { Home } from './pages/Home'; +import { Products } from './pages/Products'; +import Login from './pages/Login'; +import { Register } from './pages/Register'; +import { Orders } from './pages/Orders'; const App = () => { return ( @@ -38,7 +38,7 @@ const App = () => { - ) -} + ); +}; -export default App \ No newline at end of file +export default App; diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx deleted file mode 100644 index 149097e..0000000 --- a/frontend/src/components/Navbar.jsx +++ /dev/null @@ -1,70 +0,0 @@ -import { Link } from 'react-router-dom' -import { useApp } from '../context/AppContext' - -export function Navbar() { - const { user } = useApp() - - return ( - - ) -} \ No newline at end of file diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx index 149097e..ed353d7 100644 --- a/frontend/src/components/Navbar.tsx +++ b/frontend/src/components/Navbar.tsx @@ -1,15 +1,18 @@ -import { Link } from 'react-router-dom' -import { useApp } from '../context/AppContext' +import { Link } from 'react-router-dom'; +import { useApp } from '../context/AppContext'; export function Navbar() { - const { user } = useApp() + const { user } = useApp(); return ( - ) -} \ No newline at end of file + ); +} diff --git a/frontend/src/context/AppContext.tsx b/frontend/src/context/AppContext.tsx index 36575bf..6fc794f 100644 --- a/frontend/src/context/AppContext.tsx +++ b/frontend/src/context/AppContext.tsx @@ -36,17 +36,17 @@ export function AppProvider({ children }: AppProviderProps) { useEffect(() => { const storedToken = localStorage.getItem('token'); const storedUser = localStorage.getItem('user'); - + if (storedToken && storedUser) { setToken(storedToken); setUser(JSON.parse(storedUser)); } - + const storedCart = localStorage.getItem('cart'); if (storedCart) { setCart(JSON.parse(storedCart)); } - + setLoading(false); }, []); @@ -87,9 +87,7 @@ export function AppProvider({ children }: AppProviderProps) { const existingItem = prevCart.find((item) => item.id === product.id); if (existingItem) { return prevCart.map((item) => - item.id === product.id - ? { ...item, quantity: item.quantity + 1 } - : item + item.id === product.id ? { ...item, quantity: item.quantity + 1 } : item ); } return [...prevCart, { ...product, quantity: 1 }]; @@ -106,9 +104,7 @@ export function AppProvider({ children }: AppProviderProps) { return; } setCart((prevCart: CartItem[]) => - prevCart.map((item) => - item.id === productId ? { ...item, quantity } : item - ) + prevCart.map((item) => (item.id === productId ? { ...item, quantity } : item)) ); }; diff --git a/frontend/src/context/loaders/LoaderExample.tsx b/frontend/src/context/loaders/LoaderExample.tsx index 1a580ed..913e806 100644 --- a/frontend/src/context/loaders/LoaderExample.tsx +++ b/frontend/src/context/loaders/LoaderExample.tsx @@ -7,12 +7,12 @@ export const LoaderExample = () => { // Pattern A: Manual Control const handleManualLoad = async () => { - showLoader("Processing manual task..."); - + showLoader('Processing manual task...'); + try { // Simulate an async operation - await new Promise(resolve => setTimeout(resolve, 2000)); - + await new Promise((resolve) => setTimeout(resolve, 2000)); + addNotification({ type: 'success', title: 'Manual Task Complete', @@ -20,6 +20,7 @@ export const LoaderExample = () => { duration: 3000, }); } catch (err) { + console.error(err); addNotification({ type: 'error', title: 'Manual Task Failed', @@ -34,15 +35,12 @@ export const LoaderExample = () => { // Pattern B: withLoader Helper (Cleanest) const handleWithLoader = async () => { try { - await withLoader( - async () => { - // Simulate an async operation - await new Promise(resolve => setTimeout(resolve, 1500)); - return 'Success!'; - }, - "Processing with withLoader..." - ); - + await withLoader(async () => { + // Simulate an async operation + await new Promise((resolve) => setTimeout(resolve, 1500)); + return 'Success!'; + }, 'Processing with withLoader...'); + addNotification({ type: 'success', title: 'withLoader Task Complete', @@ -50,6 +48,7 @@ export const LoaderExample = () => { duration: 3000, }); } catch (err) { + console.error(err); addNotification({ type: 'error', title: 'withLoader Task Failed', @@ -61,12 +60,12 @@ export const LoaderExample = () => { // Pattern C: Long-running task const handleLongLoad = async () => { - showLoader("Processing long-running task..."); - + showLoader('Processing long-running task...'); + try { // Simulate a longer async operation - await new Promise(resolve => setTimeout(resolve, 4000)); - + await new Promise((resolve) => setTimeout(resolve, 4000)); + addNotification({ type: 'success', title: 'Long Task Complete', @@ -74,6 +73,7 @@ export const LoaderExample = () => { duration: 3000, }); } catch (err) { + console.error(err); addNotification({ type: 'error', title: 'Long Task Failed', @@ -87,14 +87,14 @@ export const LoaderExample = () => { // Pattern D: Error simulation const handleError = async () => { - showLoader("Processing task that will fail..."); - + showLoader('Processing task that will fail...'); + try { // Simulate an error - await new Promise((_, reject) => + await new Promise((_, reject) => setTimeout(() => reject(new Error('Simulated error')), 1500) ); - + addNotification({ type: 'success', title: 'Task Complete', @@ -102,6 +102,7 @@ export const LoaderExample = () => { duration: 3000, }); } catch (err) { + console.error(err); addNotification({ type: 'error', title: 'Task Failed', @@ -116,10 +117,10 @@ export const LoaderExample = () => { // Pattern E: Without message const handleNoMessage = async () => { showLoader(); - + try { - await new Promise(resolve => setTimeout(resolve, 2000)); - + await new Promise((resolve) => setTimeout(resolve, 2000)); + addNotification({ type: 'success', title: 'No Message Task Complete', @@ -127,6 +128,7 @@ export const LoaderExample = () => { duration: 3000, }); } catch (err) { + console.error(err); addNotification({ type: 'error', title: 'Task Failed', @@ -142,9 +144,10 @@ export const LoaderExample = () => {

Loader System Examples

- Click the buttons below to see different loader patterns in action. The loader uses React Context for state management. + Click the buttons below to see different loader patterns in action. The loader uses React + Context for state management.

- +
); -}; \ No newline at end of file +}; diff --git a/frontend/src/context/loaders/LoaderRoot.tsx b/frontend/src/context/loaders/LoaderRoot.tsx index 5ae6fee..b59b3c4 100644 --- a/frontend/src/context/loaders/LoaderRoot.tsx +++ b/frontend/src/context/loaders/LoaderRoot.tsx @@ -7,27 +7,23 @@ export const LoaderRoot = () => { if (!isLoading) return null; return createPortal( -
{/* Custom CSS Spinner (No external libs) */}
{/* Optional inner circle for style */}
-
+
- - {message && ( -

- {message} -

- )} + + {message &&

{message}

}
, document.body ); -}; \ No newline at end of file +}; diff --git a/frontend/src/context/loaders/index.ts b/frontend/src/context/loaders/index.ts index 9967013..7b52118 100644 --- a/frontend/src/context/loaders/index.ts +++ b/frontend/src/context/loaders/index.ts @@ -1,2 +1,2 @@ export { LoaderProvider, useLoader } from './useLoader'; -export { LoaderRoot } from './LoaderRoot'; \ No newline at end of file +export { LoaderRoot } from './LoaderRoot'; diff --git a/frontend/src/context/loaders/useLoader.tsx b/frontend/src/context/loaders/useLoader.tsx index fa291ca..17bd9f1 100644 --- a/frontend/src/context/loaders/useLoader.tsx +++ b/frontend/src/context/loaders/useLoader.tsx @@ -30,17 +30,17 @@ export const LoaderProvider: FC = ({ children }) => { }, []); // Helper to avoid try/finally blocks everywhere - const withLoader = useCallback(async ( - fn: () => Promise, - message?: string - ): Promise => { - showLoader(message); - try { - return await fn(); - } finally { - hideLoader(); - } - }, [showLoader, hideLoader]); + const withLoader = useCallback( + async (fn: () => Promise, message?: string): Promise => { + showLoader(message); + try { + return await fn(); + } finally { + hideLoader(); + } + }, + [showLoader, hideLoader] + ); return ( @@ -53,4 +53,4 @@ export const useLoader = () => { const context = useContext(LoaderContext); if (!context) throw new Error('useLoader must be used within a LoaderProvider'); return context; -}; \ No newline at end of file +}; diff --git a/frontend/src/context/modals/ModalComponents.tsx b/frontend/src/context/modals/ModalComponents.tsx index 21c222a..5eb2abe 100644 --- a/frontend/src/context/modals/ModalComponents.tsx +++ b/frontend/src/context/modals/ModalComponents.tsx @@ -3,15 +3,19 @@ import { ReactNode } from 'react'; // Container for the Header section export const ModalHeader = ({ children, title }: { children?: ReactNode; title?: string }) => (
- {title ? : children} + {title ? ( + + ) : ( + children + )}
); // Container for the Main Body export const ModalContent = ({ children }: { children: ReactNode }) => ( -
- {children} -
+
{children}
); // Container for Actions (Buttons) @@ -26,4 +30,4 @@ export const Modal = { Header: ModalHeader, Content: ModalContent, Actions: ModalActions, -}; \ No newline at end of file +}; diff --git a/frontend/src/context/modals/ModalExample.tsx b/frontend/src/context/modals/ModalExample.tsx index 3bbc34f..596bae2 100644 --- a/frontend/src/context/modals/ModalExample.tsx +++ b/frontend/src/context/modals/ModalExample.tsx @@ -11,14 +11,16 @@ const DeleteConfirmModal = ({ onClose }: { onClose: () => void }) => {

- -
); -}; \ No newline at end of file +}; diff --git a/frontend/src/context/modals/ModalRoot.tsx b/frontend/src/context/modals/ModalRoot.tsx index c98ed0f..a0f6ac1 100644 --- a/frontend/src/context/modals/ModalRoot.tsx +++ b/frontend/src/context/modals/ModalRoot.tsx @@ -21,13 +21,15 @@ export const ModalRoot = () => { } else { document.body.style.overflow = 'unset'; } - return () => { document.body.style.overflow = 'unset'; }; + return () => { + document.body.style.overflow = 'unset'; + }; }, [isOpen]); if (!isOpen || !content) return null; return createPortal( -
{ aria-labelledby="modal-title" > {/* Stop propagation so clicking modal content doesn't close it */} -
e.stopPropagation()} > diff --git a/frontend/src/context/toasts/ToastExample.tsx b/frontend/src/context/toasts/ToastExample.tsx index dbc52f0..69b183a 100644 --- a/frontend/src/context/toasts/ToastExample.tsx +++ b/frontend/src/context/toasts/ToastExample.tsx @@ -52,9 +52,10 @@ export const ToastExample = () => {

Toast System Examples

- Click the buttons below to see different toast notifications in action. The toast uses React Context for state management. + Click the buttons below to see different toast notifications in action. The toast uses React + Context for state management.

- +
); -}; \ No newline at end of file +}; diff --git a/frontend/src/context/toasts/ToastRoot.tsx b/frontend/src/context/toasts/ToastRoot.tsx index 3d92fc2..84cb4ed 100644 --- a/frontend/src/context/toasts/ToastRoot.tsx +++ b/frontend/src/context/toasts/ToastRoot.tsx @@ -15,22 +15,36 @@ const Icons = { ), warning: ( - + ), info: ( - + ), }; const getColors = (type: NotificationType) => { switch (type) { - case 'success': return 'bg-white border-l-4 border-green-500'; - case 'error': return 'bg-white border-l-4 border-red-500'; - case 'warning': return 'bg-white border-l-4 border-yellow-500'; - case 'info': return 'bg-white border-l-4 border-blue-500'; + case 'success': + return 'bg-white border-l-4 border-green-500'; + case 'error': + return 'bg-white border-l-4 border-red-500'; + case 'warning': + return 'bg-white border-l-4 border-yellow-500'; + case 'info': + return 'bg-white border-l-4 border-blue-500'; } }; @@ -50,9 +64,7 @@ export const ToastRoot = () => {
{Icons[toast.type]}

{toast.title}

- {toast.message && ( -

{toast.message}

- )} + {toast.message &&

{toast.message}

}
@@ -68,4 +85,4 @@ export const ToastRoot = () => {
, document.body ); -}; \ No newline at end of file +}; diff --git a/frontend/src/context/toasts/useToast.tsx b/frontend/src/context/toasts/useToast.tsx index 75b9e2d..62bb237 100644 --- a/frontend/src/context/toasts/useToast.tsx +++ b/frontend/src/context/toasts/useToast.tsx @@ -59,4 +59,4 @@ export const useToast = () => { const context = useContext(ToastContext); if (!context) throw new Error('useToast must be used within a ToastProvider'); return context; -}; \ No newline at end of file +}; diff --git a/frontend/src/hooks/useApi.js b/frontend/src/hooks/useApi.js deleted file mode 100644 index 6a28376..0000000 --- a/frontend/src/hooks/useApi.js +++ /dev/null @@ -1,88 +0,0 @@ -import axios from 'axios' - -const api = axios.create({ - baseURL: '/api', - headers: { - 'Content-Type': 'application/json', - }, -}) - -// Add token to requests if available -api.interceptors.request.use( - (config) => { - const token = localStorage.getItem('token') - if (token) { - config.headers.Authorization = `Bearer ${token}` - } - return config - }, - (error) => Promise.reject(error) -) - -// Handle response errors -api.interceptors.response.use( - (response) => response, - (error) => { - if (error.response?.status === 401) { - // Token expired or invalid - localStorage.removeItem('token') - localStorage.removeItem('user') - window.location.href = '/login' - } - return Promise.reject(error) - } -) - -export function useApi() { - return { - // Auth - login: async (email, password) => { - const response = await api.post('/auth/login', { email, password }) - return response.data - }, - register: async (userData) => { - const response = await api.post('/auth/register', userData) - return response.data - }, - getCurrentUser: async () => { - const response = await api.get('/users/me') - return response.data - }, - - // Products - getProducts: async () => { - const response = await api.get('/products') - return response.data - }, - getProduct: async (id) => { - const response = await api.get(`/products/${id}`) - return response.data - }, - createProduct: async (productData) => { - const response = await api.post('/products', productData) - return response.data - }, - updateProduct: async (id, productData) => { - const response = await api.put(`/products/${id}`, productData) - return response.data - }, - deleteProduct: async (id) => { - const response = await api.delete(`/products/${id}`) - return response.data - }, - - // Orders - getOrders: async () => { - const response = await api.get('/orders') - return response.data - }, - getOrder: async (id) => { - const response = await api.get(`/orders/${id}`) - return response.data - }, - createOrder: async (orderData) => { - const response = await api.post('/orders', orderData) - return response.data - }, - } -} \ No newline at end of file diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index 887f383..0a32994 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -1,31 +1,24 @@ -import axios from 'axios' -import { - RegisterData, - UserData, - ProductData, - OrderData, - AuthResponse -} from '../types' +import axios from 'axios'; +import { RegisterData, UserData, ProductData, OrderData, AuthResponse } from '../types'; const api = axios.create({ baseURL: '/api', headers: { 'Content-Type': 'application/json', }, -}) - +}); // Add token to requests if available api.interceptors.request.use( (config) => { - const token = localStorage.getItem('token') + const token = localStorage.getItem('token'); if (token) { - config.headers.Authorization = `Bearer ${token}` + config.headers.Authorization = `Bearer ${token}`; } - return config + return config; }, (error) => Promise.reject(error) -) +); // Handle response errors api.interceptors.response.use( @@ -33,63 +26,66 @@ api.interceptors.response.use( (error) => { if (error.response?.status === 401) { // Token expired or invalid - localStorage.removeItem('token') - localStorage.removeItem('user') - window.location.href = '/login' + localStorage.removeItem('token'); + localStorage.removeItem('user'); + window.location.href = '/login'; } - return Promise.reject(error) + return Promise.reject(error); } -) +); export function useApi() { return { // Auth login: async (email: string, password: string): Promise => { - const response = await api.post('/auth/login', { email, password }) - return response.data + const response = await api.post('/auth/login', { + email, + password, + }); + return response.data; }, register: async (userData: RegisterData): Promise => { - const response = await api.post('/auth/register', userData) - return response.data + const response = await api.post('/auth/register', userData); + return response.data; }, getCurrentUser: async (): Promise => { - const response = await api.get('/users/me') - return response.data + const response = await api.get('/users/me'); + return response.data; }, // Products getProducts: async (): Promise => { - const response = await api.get('/products') - return response.data + const response = await api.get('/products'); + return response.data; }, getProduct: async (id: string): Promise => { - const response = await api.get(`/products/${id}`) - return response.data + const response = await api.get(`/products/${id}`); + return response.data; }, createProduct: async (productData: Omit): Promise => { - const response = await api.post('/products', productData) - return response.data + const response = await api.post('/products', productData); + return response.data; }, updateProduct: async (id: string, productData: Partial): Promise => { - const response = await api.put(`/products/${id}`, productData) - return response.data + const response = await api.put(`/products/${id}`, productData); + return response.data; }, deleteProduct: async (id: string): Promise => { - await api.delete(`/products/${id}`) + await api.delete(`/products/${id}`); }, // Orders getOrders: async (): Promise => { - const response = await api.get('/orders') - return response.data + const response = await api.get('/orders'); + return response.data; }, getOrder: async (id: string): Promise => { - const response = await api.get(`/orders/${id}`) - return response.data + const response = await api.get(`/orders/${id}`); + return response.data; }, createOrder: async (orderData: Omit): Promise => { - const response = await api.post('/orders', orderData) - return response.data + const response = await api.post('/orders', orderData); + return response.data; }, - } -} \ No newline at end of file + }; +} diff --git a/frontend/src/hooks/useProducts.ts b/frontend/src/hooks/useProducts.ts index 6e1f250..f4da397 100644 --- a/frontend/src/hooks/useProducts.ts +++ b/frontend/src/hooks/useProducts.ts @@ -7,7 +7,7 @@ import { ProductData } from '../types'; export function useProducts() { const [products, setProducts] = useState([]); const [error, setError] = useState(null); - + const { getProducts } = useApi(); const { withLoader } = useLoader(); const { addNotification } = useToast(); @@ -15,28 +15,25 @@ export function useProducts() { const fetchProducts = async () => { try { setError(null); - + // Use withLoader to show loading state and handle errors - const data = await withLoader( - () => getProducts(), - 'Loading products...' - ); - + const data = await withLoader(() => getProducts(), 'Loading products...'); + setProducts(data); - - // // Show success toast - // addNotification({ - // type: 'success', - // title: 'Products Loaded', - // message: `Successfully loaded ${data.length} products.`, - // duration: 3000, - // }); - + + // // Show success toast + // addNotification({ + // type: 'success', + // title: 'Products Loaded', + // message: `Successfully loaded ${data.length} products.`, + // duration: 3000, + // }); + return data; } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Failed to load products'; setError(errorMessage); - + // Show error toast addNotification({ type: 'error', @@ -44,7 +41,7 @@ export function useProducts() { message: errorMessage, duration: 5000, }); - + return []; } }; @@ -52,6 +49,7 @@ export function useProducts() { // Optionally auto-fetch on mount useEffect(() => { fetchProducts(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return { @@ -60,4 +58,4 @@ export function useProducts() { loading: false, // Loading is handled by the global loader refetch: fetchProducts, }; -} \ No newline at end of file +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 0889f69..a0bdab8 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,9 +1,9 @@ -import React from 'react' -import ReactDOM from 'react-dom/client' -import { BrowserRouter } from 'react-router-dom' -import { AppProvider } from './context/AppContext' -import App from './App.tsx' -import './index.css' +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { BrowserRouter } from 'react-router-dom'; +import { AppProvider } from './context/AppContext'; +import App from './App.tsx'; +import './index.css'; ReactDOM.createRoot(document.getElementById('root')!).render( @@ -12,5 +12,5 @@ ReactDOM.createRoot(document.getElementById('root')!).render( - , -) + +); diff --git a/frontend/src/pages/Cart.tsx b/frontend/src/pages/Cart.tsx index 457fed2..38e24b6 100644 --- a/frontend/src/pages/Cart.tsx +++ b/frontend/src/pages/Cart.tsx @@ -29,6 +29,7 @@ export default function Cart() { clearCart(); navigate('/orders'); } catch (error) { + console.error(error); alert('Failed to create order. Please try again.'); } }; @@ -51,7 +52,7 @@ export default function Cart() { return (

Shopping Cart

- +
{cart.map((item) => (
)} - +

{item.name}

${item.price}

- +
- +

${(item.price * item.quantity).toFixed(2)}

- +
- +
); -} \ No newline at end of file +} diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index f017b7a..f44ba7c 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -1,15 +1,13 @@ -import { Link } from 'react-router-dom' -import { ModalExample } from '../context/modals/ModalExample' -import { ToastExample } from '../context/toasts/ToastExample' -import { LoaderExample } from '../context/loaders/LoaderExample' +import { Link } from 'react-router-dom'; +import { ModalExample } from '../context/modals/ModalExample'; +import { ToastExample } from '../context/toasts/ToastExample'; +import { LoaderExample } from '../context/loaders/LoaderExample'; export function Home() { return (
-

- Welcome to Crafting Shop -

+

Welcome to Crafting Shop

Your one-stop shop for premium crafting supplies

@@ -39,7 +37,8 @@ export function Home() {

Modal System Demo

- Test our modal system with this interactive example. The modal uses React Context for state management. + Test our modal system with this interactive example. The modal uses React Context for + state management.

@@ -47,7 +46,8 @@ export function Home() {

Toast System Demo

- Test our toast notification system with this interactive example. The toast uses React Context for state management. + Test our toast notification system with this interactive example. The toast uses React + Context for state management.

@@ -55,10 +55,11 @@ export function Home() {

Loader System Demo

- Test our global loading system with this interactive example. The loader uses React Context for state management. + Test our global loading system with this interactive example. The loader uses React + Context for state management.

- ) + ); } diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index b48babc..58a907b 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -1,52 +1,52 @@ -import { useState } from 'react' -import { useNavigate, Link } from 'react-router-dom' -import { useApp } from '../context/AppContext' -import { useApi } from '../hooks/useApi' -import { User } from '../types' +import { useState } from 'react'; +import { useNavigate, Link } from 'react-router-dom'; +import { useApp } from '../context/AppContext'; +import { useApi } from '../hooks/useApi'; +import { User } from '../types'; export default function Login() { - const [email, setEmail] = useState('') - const [password, setPassword] = useState('') - const [error, setError] = useState('') - const [loading, setLoading] = useState(false) - - const navigate = useNavigate() - const { login } = useApp() - const { login: loginApi } = useApi() + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + const navigate = useNavigate(); + const { login } = useApp(); + const { login: loginApi } = useApi(); const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - setError('') - setLoading(true) + e.preventDefault(); + setError(''); + setLoading(true); try { - const response = await loginApi(email, password) + const response = await loginApi(email, password); // Convert UserData to User type const user: User = { id: parseInt(response.user.id), username: response.user.username, email: response.user.email, - } - login(user, response.token) - navigate('/') + }; + login(user, response.token); + navigate('/'); } catch (err) { - setError(err instanceof Error ? err.message : 'Login failed. Please try again.') + setError(err instanceof Error ? err.message : 'Login failed. Please try again.'); } finally { - setLoading(false) + setLoading(false); } - } + }; return (

Login

- +
{error && (
{error}
)} - +
- +
- +
- ) -} \ No newline at end of file + ); +} diff --git a/frontend/src/pages/Orders.tsx b/frontend/src/pages/Orders.tsx index 167c806..0d76ab1 100644 --- a/frontend/src/pages/Orders.tsx +++ b/frontend/src/pages/Orders.tsx @@ -1,34 +1,35 @@ -import { useEffect, useState } from 'react' -import { useNavigate } from 'react-router-dom' -import { useApp } from '../context/AppContext' -import { useApi } from '../hooks/useApi' -import { OrderData } from '../types' +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useApp } from '../context/AppContext'; +import { useApi } from '../hooks/useApi'; +import { OrderData } from '../types'; export function Orders() { - const [orders, setOrders] = useState([]) - const [loading, setLoading] = useState(true) - const navigate = useNavigate() - const { user } = useApp() - const { getOrders } = useApi() + const [orders, setOrders] = useState([]); + const [loading, setLoading] = useState(true); + const navigate = useNavigate(); + const { user } = useApp(); + const { getOrders } = useApi(); useEffect(() => { if (!user) { - navigate('/login') - return + navigate('/login'); + return; } - fetchOrders() - }, [user, navigate]) + fetchOrders(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [user, navigate]); const fetchOrders = async () => { try { - const data = await getOrders() - setOrders(data) + const data = await getOrders(); + setOrders(data); } catch (error) { - console.error('Error fetching orders:', error) + console.error('Error fetching orders:', error); } finally { - setLoading(false) + setLoading(false); } - } + }; const getStatusColor = (status: string): string => { const colors: Record = { @@ -37,22 +38,22 @@ export function Orders() { shipped: 'bg-purple-900 text-purple-200 border-purple-700', delivered: 'bg-green-900 text-green-200 border-green-700', cancelled: 'bg-red-900 text-red-200 border-red-700', - } - return colors[status] || 'bg-gray-900 text-gray-200 border-gray-700' - } + }; + return colors[status] || 'bg-gray-900 text-gray-200 border-gray-700'; + }; if (loading) { return (
Loading orders...
- ) + ); } return (

My Orders

- + {orders.length === 0 ? (

You have no orders yet

@@ -72,9 +73,7 @@ export function Orders() { >
-

- Order #{order.id} -

+

Order #{order.id}

{new Date(order.created_at).toLocaleDateString()}

@@ -87,7 +86,7 @@ export function Orders() { {order.status.charAt(0).toUpperCase() + order.status.slice(1)}
- +
{order.items.map((item) => (

Product #{item.product_id}

-

- Quantity: {item.quantity} -

+

Quantity: {item.quantity}

${(item.price * item.quantity).toFixed(2)} @@ -106,18 +103,14 @@ export function Orders() {

))}
- +
- {order.shipping_address && ( - Ship to: {order.shipping_address} - )} + {order.shipping_address && Ship to: {order.shipping_address}}
Total:{' '} - - ${order.total_amount} - + ${order.total_amount}
@@ -125,5 +118,5 @@ export function Orders() {
)}
- ) -} \ No newline at end of file + ); +} diff --git a/frontend/src/pages/Products.tsx b/frontend/src/pages/Products.tsx index 757b515..ac36019 100644 --- a/frontend/src/pages/Products.tsx +++ b/frontend/src/pages/Products.tsx @@ -1,10 +1,10 @@ -import { useApp } from '../context/AppContext' -import { useProducts } from '../hooks/useProducts' -import { CartItem } from '../types' +import { useApp } from '../context/AppContext'; +import { useProducts } from '../hooks/useProducts'; +import { CartItem } from '../types'; export function Products() { - const { products, refetch } = useProducts() - const { addToCart } = useApp() + const { products, refetch } = useProducts(); + const { addToCart } = useApp(); return (
@@ -31,19 +31,11 @@ export function Products() { /> )}
-

- {product.name} -

-

- {product.description} -

+

{product.name}

+

{product.description}

- - ${product.price} - - - Stock: {product.stock} - + ${product.price} + Stock: {product.stock}
)}
- ) -} \ No newline at end of file + ); +} diff --git a/frontend/src/pages/Register.jsx b/frontend/src/pages/Register.jsx deleted file mode 100644 index 58bfe49..0000000 --- a/frontend/src/pages/Register.jsx +++ /dev/null @@ -1,177 +0,0 @@ -import { useState } from 'react' -import { useNavigate, Link } from 'react-router-dom' -import { useApi } from '../hooks/useApi.js' - -export function Register() { - const [formData, setFormData] = useState({ - email: '', - username: '', - password: '', - confirmPassword: '', - first_name: '', - last_name: '', - }) - const [error, setError] = useState('') - const [loading, setLoading] = useState(false) - - const navigate = useNavigate() - const { register } = useApi() - - const handleChange = (e) => { - setFormData({ - ...formData, - [e.target.name]: e.target.value, - }) - } - - const handleSubmit = async (e) => { - e.preventDefault() - setError('') - - if (formData.password !== formData.confirmPassword) { - setError('Passwords do not match') - return - } - - if (formData.password.length < 6) { - setError('Password must be at least 6 characters') - return - } - - setLoading(true) - - try { - await register({ - email: formData.email, - username: formData.username, - password: formData.password, - first_name: formData.first_name, - last_name: formData.last_name, - }) - navigate('/login') - } catch (err) { - setError(err.response?.data?.error || 'Registration failed. Please try again.') - } finally { - setLoading(false) - } - } - - return ( -
-

Register

- -
- {error && ( -
- {error} -
- )} - -
-
- - -
- -
- - -
-
- -
- - -
- -
- - -
- -
- - -
- -
- - -
- - -
- -

- Already have an account?{' '} - - Login - -

-
- ) -} \ No newline at end of file diff --git a/frontend/src/pages/Register.tsx b/frontend/src/pages/Register.tsx index bc439b5..989bc5f 100644 --- a/frontend/src/pages/Register.tsx +++ b/frontend/src/pages/Register.tsx @@ -1,14 +1,14 @@ -import { useState, FormEvent, ChangeEvent } from 'react' -import { useNavigate, Link } from 'react-router-dom' -import { useApi } from '../hooks/useApi' +import { useState, FormEvent, ChangeEvent } from 'react'; +import { useNavigate, Link } from 'react-router-dom'; +import { useApi } from '../hooks/useApi'; interface FormData { - email: string - username: string - password: string - confirmPassword: string - first_name: string - last_name: string + email: string; + username: string; + password: string; + confirmPassword: string; + first_name: string; + last_name: string; } export function Register() { @@ -19,35 +19,35 @@ export function Register() { confirmPassword: '', first_name: '', last_name: '', - }) - const [error, setError] = useState('') - const [loading, setLoading] = useState(false) - - const navigate = useNavigate() - const { register } = useApi() + }); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + const navigate = useNavigate(); + const { register } = useApi(); const handleChange = (e: ChangeEvent) => { setFormData({ ...formData, [e.target.name]: e.target.value, - }) - } + }); + }; const handleSubmit = async (e: FormEvent) => { - e.preventDefault() - setError('') + e.preventDefault(); + setError(''); if (formData.password !== formData.confirmPassword) { - setError('Passwords do not match') - return + setError('Passwords do not match'); + return; } if (formData.password.length < 6) { - setError('Password must be at least 6 characters') - return + setError('Password must be at least 6 characters'); + return; } - setLoading(true) + setLoading(true); try { await register({ @@ -56,26 +56,26 @@ export function Register() { password: formData.password, first_name: formData.first_name, last_name: formData.last_name, - }) - navigate('/login') + }); + navigate('/login'); } catch (err: any) { - setError(err.response?.data?.error || 'Registration failed. Please try again.') + setError(err.response?.data?.error || 'Registration failed. Please try again.'); } finally { - setLoading(false) + setLoading(false); } - } + }; return (

Register

- +
{error && (
{error}
)} - +
- +
- +
- +
- +
- +
- +
- ) -} \ No newline at end of file + ); +} diff --git a/frontend/src/store/useStore.js b/frontend/src/store/useStore.js deleted file mode 100644 index 191b172..0000000 --- a/frontend/src/store/useStore.js +++ /dev/null @@ -1,48 +0,0 @@ -import { create } from 'zustand' -import { persist } from 'zustand/middleware' - -export const useStore = create( - persist( - (set) => ({ - user: null, - token: null, - cart: [], - - setUser: (user) => set({ user }), - setToken: (token) => set({ token }), - logout: () => set({ user: null, token: null, cart: [] }), - - addToCart: (product) => - set((state) => { - const existingItem = state.cart.find((item) => item.id === product.id) - if (existingItem) { - return { - cart: state.cart.map((item) => - item.id === product.id - ? { ...item, quantity: item.quantity + 1 } - : item - ), - } - } - return { cart: [...state.cart, { ...product, quantity: 1 }] } - }), - - removeFromCart: (productId) => - set((state) => ({ - cart: state.cart.filter((item) => item.id !== productId), - })), - - updateCartQuantity: (productId, quantity) => - set((state) => ({ - cart: state.cart.map((item) => - item.id === productId ? { ...item, quantity } : item - ), - })), - - clearCart: () => set({ cart: [] }), - }), - { - name: 'crafting-shop-storage', - } - ) -) \ No newline at end of file diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 643ce90..115fcfe 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -1,4 +1,4 @@ export interface ApiResponse { data: T; message?: string; -} \ No newline at end of file +} diff --git a/frontend/src/types/modal.ts b/frontend/src/types/modal.ts index 03feb3e..abacf3b 100644 --- a/frontend/src/types/modal.ts +++ b/frontend/src/types/modal.ts @@ -1,3 +1,3 @@ export interface ModalContentProps { onClose: () => void; -} \ No newline at end of file +} diff --git a/frontend/src/types/order.ts b/frontend/src/types/order.ts index 55eff94..edce5cd 100644 --- a/frontend/src/types/order.ts +++ b/frontend/src/types/order.ts @@ -21,4 +21,4 @@ export interface Order { total_amount: number; shipping_address: string; items: OrderItem[]; -} \ No newline at end of file +} diff --git a/frontend/src/types/product.ts b/frontend/src/types/product.ts index 9f60b61..4a4046a 100644 --- a/frontend/src/types/product.ts +++ b/frontend/src/types/product.ts @@ -23,4 +23,4 @@ export interface CartItem { quantity: number; image_url?: string; [key: string]: any; -} \ No newline at end of file +} diff --git a/frontend/src/types/user.ts b/frontend/src/types/user.ts index 6a1b3a5..cdf1b6a 100644 --- a/frontend/src/types/user.ts +++ b/frontend/src/types/user.ts @@ -27,4 +27,4 @@ export interface RegisterData { export interface AuthResponse { token: string; user: UserData; -} \ No newline at end of file +}