Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
name: Tests

on:
push:
branches: [ main ]
pull_request:
branches: [ main ]

jobs:
test:
runs-on: ubuntu-24.04

env:
PYTHONPATH: ${{ github.workspace }}:${{ github.workspace }}/web

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.10'

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pytest
pip install -r tests/requirements.txt
pip install -r web/requirements.txt

- name: Run tests
run: |
pytest tests/ -v --tb=short

- name: Run tests with coverage
run: |
pytest tests/ --cov=. --cov-report=term --cov-report=xml

- name: Upload coverage reports
uses: codecov/codecov-action@v4
if: success()
with:
file: ./coverage.xml
fail_ci_if_error: false
15 changes: 1 addition & 14 deletions build_manager/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
import redis
import dill
from enum import Enum
from utils import RateLimiter
import logging
import hashlib
from metadata_manager import RemoteInfo
Expand Down Expand Up @@ -136,14 +135,6 @@ def __init__(self,
self.__task_queue = redis_task_queue_name
self.__outdir = outdir

# Initialide an IP-based rate limiter.
# Allow 10 builds per hour per client
self.__ip_rate_limiter = RateLimiter(
redis_host=redis_host,
redis_port=redis_port,
time_window_sec=3600,
allowed_requests=10
)
self.__build_entry_prefix = "buildmeta-"
self.logger = logging.getLogger(__name__)
self.logger.info(
Expand Down Expand Up @@ -218,21 +209,17 @@ def __generate_build_id(self, build_info: BuildInfo) -> str:
return bid

def submit_build(self,
build_info: BuildInfo,
client_ip: str) -> str:
build_info: BuildInfo) -> str:
"""
Submit a new build request, generate a build ID, and queue the
build for processing.

Parameters:
build_info (BuildInfo): The build information.
client_ip (str): The IP address of the client submitting the
build request.

Returns:
str: The generated build ID for the submitted build.
"""
self.__ip_rate_limiter.count(client_ip)
build_id = self.__generate_build_id(build_info)
self.__insert_build_info(build_id=build_id, build_info=build_info)
self.__queue_build(build_id=build_id)
Expand Down
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ services:
CBS_REMOTES_RELOAD_TOKEN: ${CBS_REMOTES_RELOAD_TOKEN}
PYTHONPATH: /app
CBS_BUILD_TIMEOUT_SEC: ${CBS_BUILD_TIMEOUT_SEC:-900}
FORWARDED_ALLOW_IPS: ${FORWARDED_ALLOW_IPS:-*}
volumes:
- ./base:/base:rw
depends_on:
Expand Down
3 changes: 3 additions & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[pytest]
testpaths = tests
pythonpath = .
5 changes: 5 additions & 0 deletions tests/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@

pytest-asyncio==0.21.1
pytest-cov==4.1.0
httpx==0.25.2
pytest-mock==3.12.0
283 changes: 283 additions & 0 deletions tests/web/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,283 @@
"""
Pytest configuration and shared fixtures for end-to-end tests.

This module provides fixtures for setting up test environment including:
- Test client with FastAPI app
- Mock git repository
- Predefined remotes.json to avoid version fetching
- Remote reload authentication tokens
"""
import os
import json
import tempfile
import shutil
from typing import Generator
from unittest.mock import Mock, MagicMock

import pytest
from fastapi.testclient import TestClient


# Sample remotes.json for testing - no version fetching needed
TEST_REMOTES_JSON = [
{
"name": "test-remote-1",
"url": "https://github.com/test/ardupilot.git",
"vehicles": [
{
"name": "Copter",
"releases": [
{
"release_type": "latest",
"version_number": "4.6.0",
"commit_reference": "refs/heads/master"
},
{
"release_type": "stable",
"version_number": "4.3.0",
"commit_reference": "refs/tags/Copter-4.3.0"
}
]
},
{
"name": "Plane",
"releases": [
{
"release_type": "latest",
"version_number": "4.5.0",
"commit_reference": "refs/heads/master"
}
]
}
]
},
{
"name": "test-remote-2",
"url": "https://github.com/another/ardupilot.git",
"vehicles": [
{
"name": "Rover",
"releases": [
{
"release_type": "Custom",
"version_number": "Custom",
"commit_reference": "refs/tags/Rover-4.2.0"
}
]
}
]
}
]


@pytest.fixture(scope="session")
def test_base_dir() -> Generator[str, None, None]:
"""
Create a temporary base directory structure for testing.

Yields:
str: Path to the temporary base directory
"""
temp_dir = tempfile.mkdtemp(prefix="custombuild_test_")

# Create required subdirectories
subdirs = ["artifacts", "configs", "workdir", "secrets", "ardupilot"]
for subdir in subdirs:
os.makedirs(os.path.join(temp_dir, subdir), exist_ok=True)

# Create remotes.json with test data
remotes_json_path = os.path.join(temp_dir, "configs", "remotes.json")
with open(remotes_json_path, "w") as f:
json.dump(TEST_REMOTES_JSON, f, indent=2)

# Create remote reload token file
token_file_path = os.path.join(temp_dir, "secrets", "reload_token")
with open(token_file_path, "w") as f:
f.write("test-remote-reload-token-12345")

yield temp_dir

# Cleanup
shutil.rmtree(temp_dir, ignore_errors=True)


@pytest.fixture
def mock_git_repo():
"""
Create a mock GitRepo object.

Returns:
Mock: Mock GitRepo instance
"""
mock_repo = Mock()
mock_repo.path = "/tmp/test/ardupilot"
mock_repo.get_current_commit_hash.return_value = "abc123def456"
mock_repo.checkout_commit.return_value = True
mock_repo.get_tags.return_value = ["Copter-4.3.0", "Copter-4.4.0"]
mock_repo.get_checkout_lock.return_value = MagicMock()
return mock_repo


@pytest.fixture
def mock_ap_src_metadata_fetcher():
"""
Create a mock APSourceMetadataFetcher for testing.

Returns:
Mock: Mock APSourceMetadataFetcher instance
"""
return Mock()


@pytest.fixture
def mock_versions_fetcher(test_base_dir):
"""
Create a mock VersionsFetcher that doesn't actually fetch versions.

This allows tests to run without starting background threads or
making actual git operations.

Args:
test_base_dir: Test base directory fixture

Returns:
Mock: Mock VersionsFetcher instance
"""
from metadata_manager.versions_fetcher import RemoteInfo

mock_fetcher = Mock()

# Mock the reload_remotes_json method
mock_fetcher.reload_remotes_json = Mock(return_value=None)

# Mock get_all_remotes_info to return test remotes
test_remotes = [
RemoteInfo(name="test-remote-1", url="https://github.com/test/ardupilot.git"),
RemoteInfo(name="test-remote-2", url="https://github.com/another/ardupilot.git")
]
mock_fetcher.get_all_remotes_info = Mock(return_value=test_remotes)

# Mock start/stop methods (no-op for tests)
mock_fetcher.start = Mock()
mock_fetcher.stop = Mock()

return mock_fetcher


@pytest.fixture
def mock_build_manager():
"""
Create a mock BuildManager for testing.

Returns:
Mock: Mock BuildManager instance
"""
mock_manager = Mock()
mock_manager.submit_build = Mock(return_value="test-build-id-123")
mock_manager.get_build_progress = Mock(return_value={
"build_id": "test-build-id-123",
"status": "queued",
"progress": 0
})
return mock_manager


@pytest.fixture
def mock_vehicles_manager():
"""
Create a mock VehiclesManager for testing.

Returns:
Mock: Mock VehiclesManager instance
"""
mock_manager = Mock()
mock_manager.get_vehicle_names = Mock(return_value=["Copter", "Plane", "Rover"])
return mock_manager


@pytest.fixture
def app_with_mocked_dependencies(
test_base_dir,
mock_git_repo,
mock_versions_fetcher,
mock_build_manager,
mock_vehicles_manager,
):
"""
Create a FastAPI app instance with mocked dependencies.

This fixture sets up the application without requiring actual:
- Git repository cloning
- Version fetching background tasks
- Redis connection
- Build artifacts

Args:
test_base_dir: Test base directory
mock_git_repo: Mock git repository
mock_versions_fetcher: Mock versions fetcher
mock_build_manager: Mock build manager
mock_vehicles_manager: Mock vehicles manager

Yields:
FastAPI: Configured FastAPI application instance
"""
from contextlib import asynccontextmanager
from fastapi import FastAPI
from slowapi.middleware import SlowAPIMiddleware
from slowapi.errors import RateLimitExceeded
from web.api.v1 import router as v1_router
from web.core.limiter import limiter, rate_limit_exceeded_handler

# Set environment variables for test configuration
os.environ["CBS_BASEDIR"] = test_base_dir
os.environ["CBS_REDIS_HOST"] = "localhost"
os.environ["CBS_REDIS_PORT"] = "6379"
os.environ["CBS_ENABLE_INBUILT_BUILDER"] = "0" # Disable builder for tests

@asynccontextmanager
async def test_lifespan(app: FastAPI):
"""Test lifespan that doesn't start background tasks."""
# Setup: Attach mocked dependencies to app state
app.state.repo = mock_git_repo
app.state.versions_fetcher = mock_versions_fetcher
app.state.vehicles_manager = mock_vehicles_manager
app.state.build_manager = mock_build_manager
app.state.limiter = limiter

# Create mock AP source metadata fetcher
mock_ap_src_fetcher = Mock()
app.state.ap_src_metadata_fetcher = mock_ap_src_fetcher

# Don't start background tasks in test mode
# versions_fetcher.start()
# cleaner.start()
# progress_updater.start()

yield

# Shutdown logic also skipped

app = FastAPI(title="CustomBuild Test API", lifespan=test_lifespan)

app.add_middleware(SlowAPIMiddleware)
app.add_exception_handler(RateLimitExceeded, rate_limit_exceeded_handler)

app.include_router(v1_router, prefix="/api")

return app


@pytest.fixture
def client(app_with_mocked_dependencies) -> Generator[TestClient, None, None]:
"""
Create a TestClient for making requests to the app.

Args:
app_with_mocked_dependencies: FastAPI app with mocked dependencies

Yields:
TestClient: Test client for making API requests
"""
with TestClient(app_with_mocked_dependencies) as test_client:
yield test_client
Loading
Loading