Files
copilot-swe-agent[bot] 174e4890dd feat(database): integrate wizard into settings page, improve accessibility and test coverage
- Add "DB Wizard" link button to settings page header
- Add help_link to database_url SETTING_METADATA pointing to /database-wizard
- Add help_link rendering in settings template for any setting with a help_link
- Fix SQLite whitespace path handling in build_connection_string
- Add dark mode CSS overrides for wizard template
- Add aria-describedby for all form inputs with help text
- Add prefers-reduced-motion media query for smooth scrolling
- Expand test coverage: 106 tests (up from 49)
  - db_wizard.py: 100% coverage
  - db_wizard view: 100% coverage
  - database.py API: 97.37% coverage
  - db_migrate.py: 96.60% coverage

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-06 10:02:04 +00:00

379 lines
14 KiB
Python

"""Tests for app/utils/db_wizard.py module."""
from unittest.mock import MagicMock
import pytest
from app.utils.db_wizard import (
_get_server_version,
build_connection_string,
get_supported_backends,
parse_connection_string,
validate_url_format,
)
from app.utils.db_wizard import (
test_connection as db_test_connection,
)
@pytest.mark.unit
class TestGetSupportedBackends:
"""Tests for get_supported_backends function."""
def test_returns_list(self):
"""Test that it returns a non-empty list."""
result = get_supported_backends()
assert isinstance(result, list)
assert len(result) >= 3
def test_each_backend_has_required_keys(self):
"""Test that each backend has expected keys."""
required_keys = {"id", "label", "description", "requires_host"}
for backend in get_supported_backends():
assert required_keys.issubset(set(backend.keys())), f"Missing keys in {backend.get('id')}"
def test_includes_sqlite(self):
"""Test that SQLite is included."""
ids = [b["id"] for b in get_supported_backends()]
assert "sqlite" in ids
def test_includes_postgresql(self):
"""Test that PostgreSQL is included."""
ids = [b["id"] for b in get_supported_backends()]
assert "postgresql" in ids
def test_includes_mysql(self):
"""Test that MySQL is included."""
ids = [b["id"] for b in get_supported_backends()]
assert "mysql" in ids
def test_sqlite_does_not_require_host(self):
"""Test that SQLite backend does not require host."""
sqlite = next(b for b in get_supported_backends() if b["id"] == "sqlite")
assert sqlite["requires_host"] is False
def test_postgresql_requires_host(self):
"""Test that PostgreSQL backend requires host."""
pg = next(b for b in get_supported_backends() if b["id"] == "postgresql")
assert pg["requires_host"] is True
def test_mysql_default_port(self):
"""Test that MySQL has default port 3306."""
mysql = next(b for b in get_supported_backends() if b["id"] == "mysql")
assert mysql["default_port"] == 3306
@pytest.mark.unit
class TestBuildConnectionString:
"""Tests for build_connection_string function."""
def test_sqlite_default_path(self):
"""Test building a SQLite URL with default path."""
url = build_connection_string(backend="sqlite")
assert url == "sqlite:///./app/database.db"
def test_sqlite_custom_path(self):
"""Test building a SQLite URL with custom path."""
url = build_connection_string(backend="sqlite", sqlite_path="/data/mydb.db")
assert url == "sqlite:////data/mydb.db"
def test_sqlite_whitespace_path(self):
"""Test building a SQLite URL with whitespace-only path uses default."""
url = build_connection_string(backend="sqlite", sqlite_path=" ")
assert url == "sqlite:///./app/database.db"
def test_postgresql_basic(self):
"""Test building a basic PostgreSQL URL."""
url = build_connection_string(
backend="postgresql",
host="localhost",
database="docuelevate",
username="user",
password="pass",
)
assert url == "postgresql://user:pass@localhost:5432/docuelevate"
def test_postgresql_with_ssl(self):
"""Test building a PostgreSQL URL with SSL."""
url = build_connection_string(
backend="postgresql",
host="rds.amazonaws.com",
database="docuelevate",
username="admin",
password="secret",
ssl_mode="require",
)
assert "sslmode=require" in url
assert "postgresql://admin:secret@rds.amazonaws.com:5432/docuelevate" in url
def test_postgresql_custom_port(self):
"""Test building a PostgreSQL URL with custom port."""
url = build_connection_string(
backend="postgresql",
host="localhost",
port=5433,
database="testdb",
username="user",
password="pass",
)
assert ":5433/" in url
def test_mysql_basic(self):
"""Test building a MySQL URL."""
url = build_connection_string(
backend="mysql",
host="localhost",
database="docuelevate",
username="root",
password="password",
)
assert url.startswith("mysql+pymysql://")
assert "charset=utf8mb4" in url
def test_mysql_no_duplicate_charset(self):
"""Test that charset is not duplicated when passed in extra_options."""
url = build_connection_string(
backend="mysql",
host="localhost",
database="docuelevate",
username="root",
password="pass",
extra_options="charset=utf8mb4",
)
assert url.count("charset=utf8mb4") == 1
def test_mysql_extra_options(self):
"""Test MySQL URL with extra options appended."""
url = build_connection_string(
backend="mysql",
host="localhost",
database="docuelevate",
username="root",
password="pass",
extra_options="connect_timeout=10",
)
assert "connect_timeout=10" in url
assert "charset=utf8mb4" in url
def test_unsupported_backend_raises(self):
"""Test that unsupported backend raises ValueError."""
with pytest.raises(ValueError, match="Unsupported backend"):
build_connection_string(backend="oracle")
def test_missing_host_raises(self):
"""Test that missing host for non-SQLite raises ValueError."""
with pytest.raises(ValueError, match="Host is required"):
build_connection_string(backend="postgresql", database="db", username="u")
def test_missing_database_raises(self):
"""Test that missing database name raises ValueError."""
with pytest.raises(ValueError, match="Database name is required"):
build_connection_string(backend="postgresql", host="localhost", username="u")
def test_missing_username_raises(self):
"""Test that missing username raises ValueError."""
with pytest.raises(ValueError, match="Username is required"):
build_connection_string(backend="postgresql", host="localhost", database="db")
def test_no_password(self):
"""Test building URL without password."""
url = build_connection_string(
backend="postgresql",
host="localhost",
database="db",
username="user",
)
assert "user@localhost" in url
assert ":@" not in url
def test_postgresql_with_extra_options(self):
"""Test PostgreSQL URL with extra query options."""
url = build_connection_string(
backend="postgresql",
host="localhost",
database="db",
username="user",
extra_options="application_name=docuelevate",
)
assert "application_name=docuelevate" in url
def test_postgresql_ssl_and_extra_options(self):
"""Test PostgreSQL URL with both SSL and extra options combined."""
url = build_connection_string(
backend="postgresql",
host="localhost",
database="db",
username="user",
ssl_mode="require",
extra_options="application_name=docuelevate",
)
assert "sslmode=require" in url
assert "application_name=docuelevate" in url
@pytest.mark.unit
class TestParseConnectionString:
"""Tests for parse_connection_string function."""
def test_parse_sqlite(self):
"""Test parsing a SQLite URL."""
result = parse_connection_string("sqlite:///./app/database.db")
assert result["valid"] is True
assert result["backend"] == "sqlite"
assert result["is_sqlite"] is True
def test_parse_postgresql(self):
"""Test parsing a PostgreSQL URL."""
result = parse_connection_string("postgresql://user:pass@host:5432/mydb")
assert result["valid"] is True
assert result["backend"] == "postgresql"
assert result["host"] == "host"
assert result["port"] == 5432
assert result["database"] == "mydb"
assert result["username"] == "user"
assert result["is_sqlite"] is False
def test_parse_mysql(self):
"""Test parsing a MySQL URL."""
result = parse_connection_string("mysql+pymysql://root:pass@localhost:3306/db")
assert result["valid"] is True
assert result["backend"] == "mysql"
def test_parse_invalid_url(self):
"""Test parsing an invalid URL returns error."""
result = parse_connection_string("not-a-valid-url://")
# Should still return a dict (make_url may or may not raise)
assert isinstance(result, dict)
def test_parse_postgresql_no_password(self):
"""Test parsing a PostgreSQL URL without password."""
result = parse_connection_string("postgresql://user@host:5432/mydb")
assert result["valid"] is True
assert result["password"] == ""
def test_parse_sqlite_memory(self):
"""Test parsing a SQLite in-memory URL."""
result = parse_connection_string("sqlite:///:memory:")
assert result["valid"] is True
assert result["is_sqlite"] is True
@pytest.mark.unit
class TestValidateUrlFormat:
"""Tests for validate_url_format function."""
def test_valid_sqlite(self):
"""Test valid SQLite URL."""
result = validate_url_format("sqlite:///./db.sqlite")
assert result["valid"] is True
assert result["backend"] == "sqlite"
def test_valid_postgresql(self):
"""Test valid PostgreSQL URL."""
result = validate_url_format("postgresql://u:p@host/db")
assert result["valid"] is True
def test_valid_mysql(self):
"""Test valid MySQL URL."""
result = validate_url_format("mysql+pymysql://u:p@host/db")
assert result["valid"] is True
def test_unsupported_backend(self):
"""Test that unsupported backends are flagged."""
result = validate_url_format("mssql://u:p@host/db")
assert result["valid"] is False
assert "Unsupported" in result.get("error", "")
def test_invalid_format(self):
"""Test that garbage input is invalid."""
result = validate_url_format("")
assert result["valid"] is False
@pytest.mark.unit
class TestTestConnection:
"""Tests for test_connection function."""
def test_sqlite_memory_succeeds(self):
"""Test connecting to an in-memory SQLite database."""
result = db_test_connection("sqlite:///:memory:")
assert result["success"] is True
assert "SQLite" in result.get("server_version", "")
def test_unreachable_host_fails(self):
"""Test that an unreachable host returns failure."""
result = db_test_connection("postgresql://u:p@192.0.2.1:5432/db", timeout=2)
assert result["success"] is False
assert result["message"] # Should contain an error message
def test_returns_backend_field(self):
"""Test that the backend field is populated on success."""
result = db_test_connection("sqlite:///:memory:")
assert result["backend"] == "sqlite"
def test_failure_returns_empty_backend(self):
"""Test that failure returns empty backend."""
result = db_test_connection("postgresql://u:p@192.0.2.1:5432/db", timeout=1)
assert result["backend"] == ""
assert result["server_version"] == ""
@pytest.mark.unit
class TestGetServerVersion:
"""Tests for _get_server_version internal function."""
def test_postgresql_version(self):
"""Test PostgreSQL version retrieval."""
mock_conn = MagicMock()
mock_conn.execute.return_value.fetchone.return_value = ("PostgreSQL 16.2 on x86_64",)
result = _get_server_version(mock_conn, "postgresql")
assert result == "PostgreSQL 16.2 on x86_64"
def test_mysql_version(self):
"""Test MySQL version retrieval."""
mock_conn = MagicMock()
mock_conn.execute.return_value.fetchone.return_value = ("8.0.36",)
result = _get_server_version(mock_conn, "mysql")
assert result == "8.0.36"
def test_sqlite_version(self):
"""Test SQLite version retrieval."""
mock_conn = MagicMock()
mock_conn.execute.return_value.fetchone.return_value = ("3.45.1",)
result = _get_server_version(mock_conn, "sqlite")
assert result == "SQLite 3.45.1"
def test_postgresql_empty_row(self):
"""Test PostgreSQL version with empty row returns empty string."""
mock_conn = MagicMock()
mock_conn.execute.return_value.fetchone.return_value = None
result = _get_server_version(mock_conn, "postgresql")
assert result == ""
def test_mysql_empty_row(self):
"""Test MySQL version with empty row returns empty string."""
mock_conn = MagicMock()
mock_conn.execute.return_value.fetchone.return_value = None
result = _get_server_version(mock_conn, "mysql")
assert result == ""
def test_sqlite_empty_row(self):
"""Test SQLite version with empty row returns empty string."""
mock_conn = MagicMock()
mock_conn.execute.return_value.fetchone.return_value = None
result = _get_server_version(mock_conn, "sqlite")
assert result == ""
def test_unknown_backend_returns_empty(self):
"""Test that an unknown backend returns empty string."""
mock_conn = MagicMock()
result = _get_server_version(mock_conn, "oracle")
assert result == ""
def test_exception_returns_empty(self):
"""Test that an exception returns empty string."""
mock_conn = MagicMock()
mock_conn.execute.side_effect = Exception("Connection lost")
result = _get_server_version(mock_conn, "postgresql")
assert result == ""