diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml new file mode 100644 index 0000000..2608a8c --- /dev/null +++ b/.github/workflows/cd.yml @@ -0,0 +1,26 @@ +name: CD + +on: + workflow_run: + workflows: ["CI"] + branches: [main] + types: + - completed + +jobs: + deploy: + runs-on: ubuntu-latest + if: ${{ github.event.workflow_run.conclusion == 'success' }} + + steps: + - name: Deploy to server + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.SERVER_HOST }} + username: ${{ secrets.SERVER_USERNAME }} + key: ${{ secrets.SERVER_SSH_KEY }} + script: | + cd /path/to/project + docker-compose pull + docker-compose up -d + docker system prune -f \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6a6835b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,116 @@ +name: CI + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + +jobs: + backend-tests: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:13 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: support_test + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + redis: + image: redis:alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - uses: actions/checkout@v2 + + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: '3.9' + + - name: Install dependencies + run: | + cd backend + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pytest pytest-asyncio + + - name: Run tests + env: + DATABASE_URL: postgresql://postgres:postgres@localhost:5432/support_test + REDIS_URL: redis://localhost:6379/0 + run: | + cd backend + pytest + + frontend-tests: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + + - name: Set up Node.js + uses: actions/setup-node@v2 + with: + node-version: '16' + + - name: Install dependencies + run: | + cd frontend + npm install + + - name: Run linter + run: | + cd frontend + npm run lint + + - name: Run tests + run: | + cd frontend + npm run test + + build: + needs: [backend-tests, frontend-tests] + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' + + steps: + - uses: actions/checkout@v2 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v1 + + - name: Login to DockerHub + uses: docker/login-action@v1 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push backend + uses: docker/build-push-action@v2 + with: + context: ./backend + push: true + tags: ${{ secrets.DOCKERHUB_USERNAME }}/support-backend:latest + + - name: Build and push frontend + uses: docker/build-push-action@v2 + with: + context: ./frontend + push: true + tags: ${{ secrets.DOCKERHUB_USERNAME }}/support-frontend:latest \ No newline at end of file diff --git a/backend/Dockerfile.test b/backend/Dockerfile.test new file mode 100644 index 0000000..aad1332 --- /dev/null +++ b/backend/Dockerfile.test @@ -0,0 +1,14 @@ +FROM python:3.9 + +WORKDIR /app + +# Устанавливаем зависимости +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +RUN pip install pytest pytest-asyncio pytest-cov + +# Копируем код приложения +COPY . . + +# Ждем доступности базы данных и запускаем тесты +CMD ["sh", "-c", "while ! nc -z test-db 5432; do sleep 1; done; pytest tests/ -v --cov=app"] \ No newline at end of file diff --git a/backend/app/tests/conftest.py b/backend/app/tests/conftest.py new file mode 100644 index 0000000..3629bf7 --- /dev/null +++ b/backend/app/tests/conftest.py @@ -0,0 +1,66 @@ +import os +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from fastapi.testclient import TestClient +from ..database import Base, get_db +from ..main import app +from ..utils.jwt import create_and_save_token +from ..crud import employees + +# Получаем URL базы данных из переменной окружения или используем значение по умолчанию +SQLALCHEMY_DATABASE_URL = os.getenv( + "DATABASE_URL", + "postgresql://postgres:postgres@localhost:5432/support_test" +) + +engine = create_engine(SQLALCHEMY_DATABASE_URL) +TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +@pytest.fixture(scope="function") +def test_db(): + # Создаем таблицы + Base.metadata.create_all(bind=engine) + + # Создаем сессию + db = TestingSessionLocal() + try: + yield db + finally: + db.close() + # Очищаем таблицы после каждого теста + Base.metadata.drop_all(bind=engine) + +@pytest.fixture(scope="function") +def test_employee(test_db): + employee_data = { + "first_name": "Test", + "last_name": "User", + "department": "IT", + "office": "101", + "password": "testpass123" + } + employee = employees.create_employee(test_db, employee_data) + return employee + +@pytest.fixture(scope="function") +def test_token(test_db, test_employee): + return create_and_save_token(test_employee.id, test_db) + +@pytest.fixture(scope="function") +def admin_token(test_db): + return create_and_save_token(-1, test_db) # -1 для админа + +@pytest.fixture(scope="function") +def test_employee_id(test_employee): + return test_employee.id + +# Переопределяем зависимость для получения БД +def override_get_db(): + db = TestingSessionLocal() + try: + yield db + finally: + db.close() + +app.dependency_overrides[get_db] = override_get_db \ No newline at end of file diff --git a/backend/app/tests/test_auth.py b/backend/app/tests/test_auth.py new file mode 100644 index 0000000..116b3aa --- /dev/null +++ b/backend/app/tests/test_auth.py @@ -0,0 +1,83 @@ +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session +from ..main import app +from ..crud import employees +from ..utils.auth import verify_password, get_password_hash + +client = TestClient(app) + +def test_login_success(test_db: Session): + # Создаем тестового сотрудника + hashed_password = get_password_hash("testpass123") + employee = employees.create_employee( + test_db, + { + "first_name": "Test", + "last_name": "User", + "department": "IT", + "office": "101", + "password": "testpass123" + } + ) + + response = client.post( + "/api/auth/login", + data={ + "username": "User", + "password": "testpass123" + } + ) + + assert response.status_code == 200 + assert "access_token" in response.json() + assert response.json()["token_type"] == "bearer" + +def test_login_wrong_password(test_db: Session): + response = client.post( + "/api/auth/login", + data={ + "username": "User", + "password": "wrongpass" + } + ) + + assert response.status_code == 401 + assert "detail" in response.json() + +def test_login_nonexistent_user(test_db: Session): + response = client.post( + "/api/auth/login", + data={ + "username": "NonExistent", + "password": "testpass123" + } + ) + + assert response.status_code == 401 + assert "detail" in response.json() + +def test_admin_login_success(): + response = client.post( + "/api/auth/admin/login", + data={ + "username": "admin", + "password": "admin123" + } + ) + + assert response.status_code == 200 + assert "access_token" in response.json() + assert response.json()["token_type"] == "bearer" + +def test_admin_login_wrong_password(): + response = client.post( + "/api/auth/admin/login", + data={ + "username": "admin", + "password": "wrongpass" + } + ) + + assert response.status_code == 401 + assert "detail" in response.json() \ No newline at end of file diff --git a/backend/app/tests/test_employees.py b/backend/app/tests/test_employees.py new file mode 100644 index 0000000..7fe2a2e --- /dev/null +++ b/backend/app/tests/test_employees.py @@ -0,0 +1,150 @@ +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session +from ..main import app +from ..crud import employees +from ..utils.auth import verify_password + +client = TestClient(app) + +def test_create_employee(test_db: Session, admin_token: str): + employee_data = { + "first_name": "John", + "last_name": "Doe", + "department": "IT", + "office": "101", + "password": "testpass123" + } + + response = client.post( + "/api/employees/", + json=employee_data, + headers={"Authorization": f"Bearer {admin_token}"} + ) + + assert response.status_code == 200 + data = response.json() + assert data["first_name"] == employee_data["first_name"] + assert data["last_name"] == employee_data["last_name"] + assert data["department"] == employee_data["department"] + assert data["office"] == employee_data["office"] + assert "id" in data + +def test_create_employee_unauthorized(): + employee_data = { + "first_name": "John", + "last_name": "Doe", + "department": "IT", + "office": "101", + "password": "testpass123" + } + + response = client.post( + "/api/employees/", + json=employee_data + ) + + assert response.status_code == 401 + +def test_get_employees(test_db: Session, admin_token: str): + # Создаем несколько тестовых сотрудников + for i in range(3): + employees.create_employee( + test_db, + { + "first_name": f"Test{i}", + "last_name": f"User{i}", + "department": "IT", + "office": f"10{i}", + "password": "testpass123" + } + ) + + response = client.get( + "/api/employees/", + headers={"Authorization": f"Bearer {admin_token}"} + ) + + assert response.status_code == 200 + data = response.json() + assert len(data) >= 3 + +def test_get_employee_by_id(test_db: Session, admin_token: str): + # Создаем тестового сотрудника + employee = employees.create_employee( + test_db, + { + "first_name": "Test", + "last_name": "User", + "department": "IT", + "office": "101", + "password": "testpass123" + } + ) + + response = client.get( + f"/api/employees/{employee.id}", + headers={"Authorization": f"Bearer {admin_token}"} + ) + + assert response.status_code == 200 + data = response.json() + assert data["id"] == employee.id + assert data["first_name"] == employee.first_name + assert data["last_name"] == employee.last_name + +def test_update_employee(test_db: Session, admin_token: str): + # Создаем тестового сотрудника + employee = employees.create_employee( + test_db, + { + "first_name": "Test", + "last_name": "User", + "department": "IT", + "office": "101", + "password": "testpass123" + } + ) + + update_data = { + "department": "HR", + "office": "202" + } + + response = client.put( + f"/api/employees/{employee.id}", + json=update_data, + headers={"Authorization": f"Bearer {admin_token}"} + ) + + assert response.status_code == 200 + data = response.json() + assert data["department"] == update_data["department"] + assert data["office"] == update_data["office"] + +def test_delete_employee(test_db: Session, admin_token: str): + # Создаем тестового сотрудника + employee = employees.create_employee( + test_db, + { + "first_name": "Test", + "last_name": "User", + "department": "IT", + "office": "101", + "password": "testpass123" + } + ) + + response = client.delete( + f"/api/employees/{employee.id}", + headers={"Authorization": f"Bearer {admin_token}"} + ) + + assert response.status_code == 200 + + # Проверяем, что сотрудник удален + get_response = client.get( + f"/api/employees/{employee.id}", + headers={"Authorization": f"Bearer {admin_token}"} + ) + assert get_response.status_code == 404 \ No newline at end of file diff --git a/backend/app/tests/test_requests.py b/backend/app/tests/test_requests.py new file mode 100644 index 0000000..dd15210 --- /dev/null +++ b/backend/app/tests/test_requests.py @@ -0,0 +1,114 @@ +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session +from ..main import app +from ..crud import requests, employees +from ..models.request import RequestStatus + +client = TestClient(app) + +def test_create_request(test_db: Session, test_token: str): + request_data = { + "title": "Test Request", + "description": "Test Description", + "priority": "low", + "status": "new" + } + + response = client.post( + "/api/requests/", + json=request_data, + headers={"Authorization": f"Bearer {test_token}"} + ) + + assert response.status_code == 200 + data = response.json() + assert data["title"] == request_data["title"] + assert data["description"] == request_data["description"] + assert data["priority"] == request_data["priority"] + assert data["status"] == RequestStatus.NEW.value + +def test_create_request_unauthorized(): + request_data = { + "title": "Test Request", + "description": "Test Description", + "priority": "low" + } + + response = client.post( + "/api/requests/", + json=request_data + ) + + assert response.status_code == 401 + +def test_get_employee_requests(test_db: Session, test_token: str, test_employee_id: int): + # Создаем несколько тестовых заявок + for i in range(3): + requests.create_request( + test_db, + { + "title": f"Test Request {i}", + "description": f"Test Description {i}", + "priority": "low", + "status": "new" + }, + test_employee_id + ) + + response = client.get( + "/api/requests/", + headers={"Authorization": f"Bearer {test_token}"} + ) + + assert response.status_code == 200 + data = response.json() + assert len(data) == 3 + assert all(req["employee_id"] == test_employee_id for req in data) + +def test_update_request_status(test_db: Session, admin_token: str): + # Создаем тестовую заявку + employee = employees.create_employee( + test_db, + { + "first_name": "Test", + "last_name": "User", + "department": "IT", + "office": "101", + "password": "testpass123" + } + ) + + request = requests.create_request( + test_db, + { + "title": "Test Request", + "description": "Test Description", + "priority": "low", + "status": "new" + }, + employee.id + ) + + response = client.put( + f"/api/requests/{request.id}", + json={"status": "in_progress"}, + headers={"Authorization": f"Bearer {admin_token}"} + ) + + assert response.status_code == 200 + assert response.json()["status"] == RequestStatus.IN_PROGRESS.value + +def test_get_request_statistics(test_db: Session, admin_token: str): + response = client.get( + "/api/requests/statistics", + headers={"Authorization": f"Bearer {admin_token}"} + ) + + assert response.status_code == 200 + data = response.json() + assert "total" in data + assert "new" in data + assert "in_progress" in data + assert "completed" in data + assert "rejected" in data \ No newline at end of file diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..82ffd62 --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,42 @@ +version: '3.8' + +services: + test-db: + image: postgres:13 + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: support_test + ports: + - "5433:5432" # Используем другой порт, чтобы не конфликтовать с основной БД + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + + test-redis: + image: redis:alpine + ports: + - "6380:6379" # Используем другой порт, чтобы не конфликтовать с основным Redis + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 5 + + backend-tests: + build: + context: ./backend + dockerfile: Dockerfile.test + environment: + DATABASE_URL: postgresql://postgres:postgres@test-db:5432/support_test + REDIS_URL: redis://test-redis:6379/0 + depends_on: + test-db: + condition: service_healthy + test-redis: + condition: service_healthy + volumes: + - ./backend:/app + - ./test-results:/app/test-results \ No newline at end of file