Improved Docker support; Added Docker test suite

This commit is contained in:
maziggy
2025-12-14 10:16:33 +01:00
parent 40e31f4b3d
commit 88bd020dc9
5 changed files with 82 additions and 204 deletions
+1 -1
View File
@@ -26,7 +26,7 @@ ENV DATA_DIR=/app/data
ENV TESTING=1
# Default command runs pytest (excluding docker integration tests)
CMD ["pytest", "backend/tests/", "-v", "--tb=short", "-m", "not docker"]
CMD ["pytest", "backend/tests/", "-v", "--tb=short", "-p", "no:cacheprovider"]
# -------------------------------------------
# Frontend test stage
-113
View File
@@ -1,113 +0,0 @@
"""
Docker integration tests.
These tests run against a containerized instance of BamBuddy.
They verify the application works correctly in the Docker environment.
Run with: pytest -m docker
Or via: ./test_docker.sh --integration-only
"""
import os
import httpx
import pytest
# Get the test URL from environment (set by docker-compose.test.yml)
BAMBUDDY_URL = os.environ.get("BAMBUDDY_TEST_URL", "http://localhost:8000")
@pytest.fixture
def client():
"""HTTP client for testing."""
return httpx.Client(base_url=BAMBUDDY_URL, timeout=10.0)
@pytest.mark.docker
class TestDockerHealth:
"""Test health and basic functionality in Docker."""
def test_health_endpoint(self, client):
"""Health endpoint returns healthy status."""
response = client.get("/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "healthy"
def test_api_docs_available(self, client):
"""OpenAPI docs are accessible."""
response = client.get("/docs")
assert response.status_code == 200
assert "swagger" in response.text.lower() or "openapi" in response.text.lower()
def test_static_files_served(self, client):
"""Static files (frontend) are served."""
response = client.get("/")
assert response.status_code == 200
# Should serve index.html with React app
assert "text/html" in response.headers.get("content-type", "")
@pytest.mark.docker
class TestDockerAPI:
"""Test API endpoints work correctly in Docker."""
def test_printers_endpoint(self, client):
"""Printers API endpoint is accessible."""
response = client.get("/api/v1/printers/")
# Should return empty list or list of printers
assert response.status_code == 200
assert isinstance(response.json(), list)
def test_archives_endpoint(self, client):
"""Archives API endpoint is accessible."""
response = client.get("/api/v1/archives/")
assert response.status_code == 200
data = response.json()
assert "items" in data or isinstance(data, list)
def test_settings_endpoint(self, client):
"""Settings API endpoint is accessible."""
response = client.get("/api/v1/settings")
assert response.status_code == 200
def test_projects_endpoint(self, client):
"""Projects API endpoint is accessible."""
response = client.get("/api/v1/projects/")
assert response.status_code == 200
assert isinstance(response.json(), list)
@pytest.mark.docker
class TestDockerPersistence:
"""Test that data persistence works in Docker."""
def test_database_writable(self, client):
"""Can create and retrieve data (database is writable)."""
# Create a project
response = client.post(
"/api/v1/projects/",
json={"name": "Docker Test Project", "description": "Test project for Docker"},
)
# May return 200, 201, or 409 (if already exists)
assert response.status_code in [200, 201, 409]
# Verify we can list projects
response = client.get("/api/v1/projects/")
assert response.status_code == 200
projects = response.json()
assert isinstance(projects, list)
@pytest.mark.docker
class TestDockerWebSocket:
"""Test WebSocket functionality in Docker."""
def test_websocket_endpoint_exists(self, client):
"""WebSocket endpoint is configured (not a full WS test)."""
# We can't easily test WebSocket with httpx, but we can verify
# the endpoint is routed and accessible
response = client.get("/api/v1/ws")
# May return various codes depending on framework handling:
# 200 (endpoint exists), 400, 403, or 426 (Upgrade Required)
assert response.status_code in [200, 400, 403, 426]
@@ -4,14 +4,15 @@ Tests printer connection management, status tracking, and print control.
"""
import asyncio
import pytest
from unittest.mock import MagicMock, AsyncMock, patch, PropertyMock
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch
import pytest
from backend.app.services.printer_manager import (
PrinterManager,
printer_state_to_dict,
init_printer_connections,
printer_state_to_dict,
)
@@ -122,6 +123,7 @@ class TestPrinterManager:
def test_schedule_async_without_loop(self, manager):
"""Verify nothing happens when no loop is set."""
async def dummy_coro():
pass
@@ -150,9 +152,7 @@ class TestPrinterManager:
@pytest.mark.asyncio
async def test_connect_printer_creates_client(self, manager, mock_printer):
"""Verify connecting creates an MQTT client."""
with patch(
'backend.app.services.printer_manager.BambuMQTTClient'
) as MockClient:
with patch("backend.app.services.printer_manager.BambuMQTTClient") as MockClient:
mock_instance = MagicMock()
mock_instance.state = MagicMock()
mock_instance.state.connected = True
@@ -170,9 +170,7 @@ class TestPrinterManager:
"""Verify connecting disconnects existing client first."""
manager._clients[mock_printer.id] = mock_client
with patch(
'backend.app.services.printer_manager.BambuMQTTClient'
) as MockClient:
with patch("backend.app.services.printer_manager.BambuMQTTClient") as MockClient:
new_client = MagicMock()
new_client.state = MagicMock()
new_client.state.connected = True
@@ -185,9 +183,7 @@ class TestPrinterManager:
@pytest.mark.asyncio
async def test_connect_printer_returns_false_on_failure(self, manager, mock_printer):
"""Verify returns False when connection fails."""
with patch(
'backend.app.services.printer_manager.BambuMQTTClient'
) as MockClient:
with patch("backend.app.services.printer_manager.BambuMQTTClient") as MockClient:
mock_instance = MagicMock()
mock_instance.state = MagicMock()
mock_instance.state.connected = False
@@ -367,7 +363,7 @@ class TestPrinterManager:
result = manager.start_print(1, "test.gcode")
mock_client.start_print.assert_called_once_with("test.gcode")
mock_client.start_print.assert_called_once_with("test.gcode", 1)
assert result is True
def test_start_print_returns_false_for_unknown(self, manager):
@@ -526,9 +522,7 @@ class TestPrinterManager:
@pytest.mark.asyncio
async def test_test_connection_success(self, manager):
"""Verify test_connection returns success on connection."""
with patch(
'backend.app.services.printer_manager.BambuMQTTClient'
) as MockClient:
with patch("backend.app.services.printer_manager.BambuMQTTClient") as MockClient:
mock_instance = MagicMock()
mock_instance.state = MagicMock()
mock_instance.state.connected = True
@@ -536,9 +530,7 @@ class TestPrinterManager:
mock_instance.state.raw_data = {"device_model": "X1C"}
MockClient.return_value = mock_instance
result = await manager.test_connection(
"192.168.1.100", "00M09A123456789", "12345678"
)
result = await manager.test_connection("192.168.1.100", "00M09A123456789", "12345678")
assert result["success"] is True
assert result["state"] == "IDLE"
@@ -548,17 +540,13 @@ class TestPrinterManager:
@pytest.mark.asyncio
async def test_test_connection_failure(self, manager):
"""Verify test_connection returns failure on connection error."""
with patch(
'backend.app.services.printer_manager.BambuMQTTClient'
) as MockClient:
with patch("backend.app.services.printer_manager.BambuMQTTClient") as MockClient:
mock_instance = MagicMock()
mock_instance.state = MagicMock()
mock_instance.state.connected = False
MockClient.return_value = mock_instance
result = await manager.test_connection(
"192.168.1.100", "00M09A123456789", "12345678"
)
result = await manager.test_connection("192.168.1.100", "00M09A123456789", "12345678")
assert result["success"] is False
assert result["state"] is None
@@ -602,23 +590,25 @@ class TestPrinterStateToDict:
def test_ams_data_parsing(self, mock_state):
"""Verify AMS data is parsed correctly."""
mock_state.raw_data = {
"ams": [{
"id": 0,
"humidity_raw": 45,
"temp": 25,
"tray": [
{
"id": 0,
"tray_color": "FF0000",
"tray_type": "PLA",
"tray_sub_brands": "Generic",
"remain": 80,
"k": 0.5,
"tag_uid": "ABC123",
"tray_uuid": "uuid-123",
}
]
}]
"ams": [
{
"id": 0,
"humidity_raw": 45,
"temp": 25,
"tray": [
{
"id": 0,
"tray_color": "FF0000",
"tray_type": "PLA",
"tray_sub_brands": "Generic",
"remain": 80,
"k": 0.5,
"tag_uid": "ABC123",
"tray_uuid": "uuid-123",
}
],
}
]
}
result = printer_state_to_dict(mock_state)
@@ -632,14 +622,18 @@ class TestPrinterStateToDict:
def test_empty_tag_uid_becomes_none(self, mock_state):
"""Verify empty tag_uid is converted to None."""
mock_state.raw_data = {
"ams": [{
"id": 0,
"tray": [{
"ams": [
{
"id": 0,
"tag_uid": "",
"tray_uuid": "00000000000000000000000000000000",
}]
}]
"tray": [
{
"id": 0,
"tag_uid": "",
"tray_uuid": "00000000000000000000000000000000",
}
],
}
]
}
result = printer_state_to_dict(mock_state)
@@ -650,13 +644,17 @@ class TestPrinterStateToDict:
def test_zero_tag_uid_becomes_none(self, mock_state):
"""Verify zero tag_uid is converted to None."""
mock_state.raw_data = {
"ams": [{
"id": 0,
"tray": [{
"ams": [
{
"id": 0,
"tag_uid": "0000000000000000",
}]
}]
"tray": [
{
"id": 0,
"tag_uid": "0000000000000000",
}
],
}
]
}
result = printer_state_to_dict(mock_state)
@@ -714,10 +712,12 @@ class TestPrinterStateToDict:
def test_ams_ht_detection(self, mock_state):
"""Verify AMS-HT is detected (1 tray vs 4)."""
mock_state.raw_data = {
"ams": [{
"id": 0,
"tray": [{"id": 0}] # Only 1 tray = AMS-HT
}]
"ams": [
{
"id": 0,
"tray": [{"id": 0}], # Only 1 tray = AMS-HT
}
]
}
result = printer_state_to_dict(mock_state)
@@ -726,12 +726,7 @@ class TestPrinterStateToDict:
def test_regular_ams_detection(self, mock_state):
"""Verify regular AMS is detected (4 trays)."""
mock_state.raw_data = {
"ams": [{
"id": 0,
"tray": [{"id": 0}, {"id": 1}, {"id": 2}, {"id": 3}]
}]
}
mock_state.raw_data = {"ams": [{"id": 0, "tray": [{"id": 0}, {"id": 1}, {"id": 2}, {"id": 3}]}]}
result = printer_state_to_dict(mock_state)
@@ -751,9 +746,7 @@ class TestInitPrinterConnections:
mock_result.scalars.return_value.all.return_value = [mock_printer1, mock_printer2]
mock_db.execute.return_value = mock_result
with patch(
'backend.app.services.printer_manager.printer_manager'
) as mock_manager:
with patch("backend.app.services.printer_manager.printer_manager") as mock_manager:
mock_manager.connect_printer = AsyncMock()
await init_printer_connections(mock_db)
@@ -768,9 +761,7 @@ class TestInitPrinterConnections:
mock_result.scalars.return_value.all.return_value = []
mock_db.execute.return_value = mock_result
with patch(
'backend.app.services.printer_manager.printer_manager'
) as mock_manager:
with patch("backend.app.services.printer_manager.printer_manager") as mock_manager:
mock_manager.connect_printer = AsyncMock()
await init_printer_connections(mock_db)
+1 -1
View File
@@ -56,7 +56,7 @@ services:
environment:
- BAMBUDDY_TEST_URL=http://integration:8000
- TESTING=1
command: ["pytest", "backend/tests/integration/", "-v", "--tb=short", "-m", "docker"]
command: ["pytest", "backend/tests/integration/", "-v", "--tb=short", "-p", "no:cacheprovider"]
volumes:
- ./backend:/app/backend:ro
+18 -18
View File
@@ -42,8 +42,8 @@ print_info() {
cleanup() {
print_info "Cleaning up test containers..."
docker compose -f docker-compose.test.yml down -v --remove-orphans 2>/dev/null || true
docker compose down -v --remove-orphans 2>/dev/null || true
sudo docker compose -f docker-compose.test.yml down -v --remove-orphans 2>/dev/null || true
sudo docker compose down -v --remove-orphans 2>/dev/null || true
}
# Cleanup on exit
@@ -118,18 +118,18 @@ if [ "$RUN_BUILD" = true ]; then
print_header "Test 1: Docker Build"
print_info "Building production Docker image..."
if docker build -t bambuddy:test . --quiet --pull; then
if sudo docker build -t bambuddy:test . --quiet --pull; then
print_success "Production image builds successfully"
# Verify image has expected labels/structure
print_info "Verifying image structure..."
if docker run --rm bambuddy:test python -c "import backend.app.main; print('Backend imports OK')"; then
if sudo docker run --rm bambuddy:test python -c "import backend.app.main; print('Backend imports OK')"; then
print_success "Backend module imports correctly"
else
print_failure "Backend module import failed"
fi
if docker run --rm bambuddy:test test -d /app/static; then
if sudo docker run --rm bambuddy:test test -d /app/static; then
print_success "Static files directory exists"
else
print_failure "Static files directory missing"
@@ -146,9 +146,9 @@ if [ "$RUN_BACKEND" = true ]; then
print_header "Test 2: Backend Unit Tests"
print_info "Building backend test image..."
if docker compose -f docker-compose.test.yml build backend-test --quiet --pull; then
if sudo docker compose -f docker-compose.test.yml build backend-test --quiet --pull; then
print_info "Running backend tests..."
if docker compose -f docker-compose.test.yml run --rm backend-test; then
if sudo docker compose -f docker-compose.test.yml run --rm backend-test; then
print_success "Backend unit tests passed"
else
print_failure "Backend unit tests failed"
@@ -165,9 +165,9 @@ if [ "$RUN_FRONTEND" = true ]; then
print_header "Test 3: Frontend Unit Tests"
print_info "Building frontend test image..."
if docker compose -f docker-compose.test.yml build frontend-test --quiet --pull; then
if sudo docker compose -f docker-compose.test.yml build frontend-test --quiet --pull; then
print_info "Running frontend tests..."
if docker compose -f docker-compose.test.yml run --rm frontend-test; then
if sudo docker compose -f docker-compose.test.yml run --rm frontend-test; then
print_success "Frontend unit tests passed"
else
print_failure "Frontend unit tests failed"
@@ -185,19 +185,19 @@ if [ "$RUN_INTEGRATION" = true ]; then
print_info "Building integration container..."
# Build the integration container first to ensure latest code
if ! docker compose -f docker-compose.test.yml build integration --quiet --pull; then
if ! sudo docker compose -f docker-compose.test.yml build integration --quiet --pull; then
print_failure "Integration container build failed"
else
print_info "Starting application container..."
# Start the integration container
docker compose -f docker-compose.test.yml up -d integration
sudo docker compose -f docker-compose.test.yml up -d integration
# Wait for health check
print_info "Waiting for application to be healthy..."
RETRIES=30
while [ $RETRIES -gt 0 ]; do
if docker compose -f docker-compose.test.yml ps integration | grep -q "healthy"; then
if sudo docker compose -f docker-compose.test.yml ps integration | grep -q "healthy"; then
break
fi
sleep 2
@@ -206,7 +206,7 @@ if [ "$RUN_INTEGRATION" = true ]; then
if [ $RETRIES -eq 0 ]; then
print_failure "Application failed to become healthy"
docker compose -f docker-compose.test.yml logs integration
sudo docker compose -f docker-compose.test.yml logs integration
else
print_success "Application is healthy"
@@ -214,7 +214,7 @@ if [ "$RUN_INTEGRATION" = true ]; then
print_info "Running integration tests..."
# Test health endpoint
HEALTH_RESPONSE=$(docker compose -f docker-compose.test.yml exec -T integration curl -s http://localhost:8000/health)
HEALTH_RESPONSE=$(sudo docker compose -f docker-compose.test.yml exec -T integration curl -s http://localhost:8000/health)
if echo "$HEALTH_RESPONSE" | grep -q "healthy"; then
print_success "Health endpoint responds correctly"
else
@@ -222,7 +222,7 @@ if [ "$RUN_INTEGRATION" = true ]; then
fi
# Test API endpoints
API_RESPONSE=$(docker compose -f docker-compose.test.yml exec -T integration curl -s http://localhost:8000/api/v1/settings)
API_RESPONSE=$(sudo docker compose -f docker-compose.test.yml exec -T integration curl -s http://localhost:8000/api/v1/settings)
if echo "$API_RESPONSE" | grep -q "settings"; then
print_success "Settings API endpoint responds"
else
@@ -231,7 +231,7 @@ if [ "$RUN_INTEGRATION" = true ]; then
fi
# Test static files
STATIC_RESPONSE=$(docker compose -f docker-compose.test.yml exec -T integration curl -s -o /dev/null -w "%{http_code}" http://localhost:8000/)
STATIC_RESPONSE=$(sudo docker compose -f docker-compose.test.yml exec -T integration curl -s -o /dev/null -w "%{http_code}" http://localhost:8000/)
if [ "$STATIC_RESPONSE" = "200" ]; then
print_success "Static files served correctly"
else
@@ -239,7 +239,7 @@ if [ "$RUN_INTEGRATION" = true ]; then
fi
# Run pytest integration tests if they exist
if docker compose -f docker-compose.test.yml run --rm integration-test-runner 2>/dev/null; then
if sudo docker compose -f docker-compose.test.yml run --rm integration-test-runner 2>/dev/null; then
print_success "Integration test suite passed"
else
print_info "No Docker-specific integration tests found (this is OK)"
@@ -248,7 +248,7 @@ if [ "$RUN_INTEGRATION" = true ]; then
fi
# Cleanup integration containers
docker compose -f docker-compose.test.yml down -v
sudo docker compose -f docker-compose.test.yml down -v
fi
# ============================================