refactor(drivers): organize backup modules into drivers subfolder
lint / docker (push) Has been cancelled
lint / docker (push) Has been cancelled
- Move all backup_*.py files to libtisbackup/drivers/ subdirectory - Move XenAPI.py and copy_vm_xcp.py to drivers/ (driver-specific) - Create drivers/__init__.py with automatic driver imports - Update tisbackup.py imports to use new structure - Add pyvmomi>=8.0.0 as mandatory dependency - Sync requirements.txt with pyproject.toml dependencies - Add pylint>=3.0.0 and pytest-cov>=6.0.0 to dev dependencies - Configure pylint and coverage tools in pyproject.toml - Add conventional commits guidelines to CLAUDE.md - Enhance .gitignore with comprehensive patterns for Python, IDEs, testing, and secrets - Update CLAUDE.md documentation with new structure and tooling Breaking Changes: - Drivers must now be imported from libtisbackup.drivers instead of libtisbackup - All backup driver files relocated to drivers/ subdirectory 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+145
@@ -0,0 +1,145 @@
|
||||
# TISBackup Test Suite
|
||||
|
||||
This directory contains the test suite for TISBackup using pytest.
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Run all tests
|
||||
```bash
|
||||
uv run pytest
|
||||
```
|
||||
|
||||
### Run tests for a specific module
|
||||
```bash
|
||||
uv run pytest tests/test_ssh.py
|
||||
```
|
||||
|
||||
### Run with verbose output
|
||||
```bash
|
||||
uv run pytest -v
|
||||
```
|
||||
|
||||
### Run tests matching a pattern
|
||||
```bash
|
||||
uv run pytest -k "ssh" -v
|
||||
```
|
||||
|
||||
### Run with coverage (requires pytest-cov)
|
||||
```bash
|
||||
uv run pytest --cov=libtisbackup --cov-report=html
|
||||
```
|
||||
|
||||
## Test Structure
|
||||
|
||||
### Current Test Modules
|
||||
|
||||
- **[test_ssh.py](test_ssh.py)** - Tests for SSH operations module
|
||||
- `TestLoadSSHPrivateKey` - Tests for key loading with Ed25519, ECDSA, and RSA support
|
||||
- `TestSSHExec` - Tests for remote command execution via SSH
|
||||
- `TestSSHModuleIntegration` - Integration tests for SSH functionality
|
||||
|
||||
## Test Categories
|
||||
|
||||
Tests are organized using pytest markers:
|
||||
|
||||
- `@pytest.mark.unit` - Unit tests for individual functions
|
||||
- `@pytest.mark.integration` - Integration tests for multiple components
|
||||
- `@pytest.mark.ssh` - SSH-related tests
|
||||
- `@pytest.mark.slow` - Long-running tests
|
||||
|
||||
### Run only unit tests
|
||||
```bash
|
||||
uv run pytest -m unit
|
||||
```
|
||||
|
||||
### Run only SSH tests
|
||||
```bash
|
||||
uv run pytest -m ssh
|
||||
```
|
||||
|
||||
## Writing New Tests
|
||||
|
||||
### Test File Naming
|
||||
- Test files should be named `test_*.py`
|
||||
- Place them in the `tests/` directory
|
||||
|
||||
### Test Class Naming
|
||||
- Test classes should start with `Test`
|
||||
- Example: `TestMyModule`
|
||||
|
||||
### Test Function Naming
|
||||
- Test functions should start with `test_`
|
||||
- Use descriptive names: `test_load_ed25519_key_success`
|
||||
|
||||
### Example Test Structure
|
||||
```python
|
||||
import pytest
|
||||
from libtisbackup.mymodule import my_function
|
||||
|
||||
class TestMyFunction:
|
||||
"""Test cases for my_function."""
|
||||
|
||||
def test_basic_functionality(self):
|
||||
"""Test basic use case."""
|
||||
result = my_function("input")
|
||||
assert result == "expected_output"
|
||||
|
||||
def test_error_handling(self):
|
||||
"""Test error handling."""
|
||||
with pytest.raises(ValueError):
|
||||
my_function(None)
|
||||
```
|
||||
|
||||
## Mocking
|
||||
|
||||
The test suite uses `pytest-mock` for mocking dependencies. Common patterns:
|
||||
|
||||
### Mocking with patch
|
||||
```python
|
||||
from unittest.mock import patch, Mock
|
||||
|
||||
def test_with_mock():
|
||||
with patch('module.function') as mock_func:
|
||||
mock_func.return_value = "mocked"
|
||||
result = my_code()
|
||||
assert result == "mocked"
|
||||
```
|
||||
|
||||
### Using pytest fixtures
|
||||
```python
|
||||
@pytest.fixture
|
||||
def mock_ssh_client():
|
||||
return Mock(spec=paramiko.SSHClient)
|
||||
|
||||
def test_with_fixture(mock_ssh_client):
|
||||
# Use the fixture
|
||||
pass
|
||||
```
|
||||
|
||||
## Coverage Goals
|
||||
|
||||
Aim for:
|
||||
- **80%+** overall code coverage
|
||||
- **90%+** for critical modules (ssh, database, base_driver)
|
||||
- **100%** for utility functions
|
||||
|
||||
## Test Configuration
|
||||
|
||||
Test configuration is in the `[tool.pytest.ini_options]` section of [pyproject.toml](../pyproject.toml):
|
||||
- Test discovery patterns
|
||||
- Output formatting
|
||||
- Markers definition
|
||||
- Minimum Python version
|
||||
|
||||
## Continuous Integration
|
||||
|
||||
Tests should pass before merging:
|
||||
```bash
|
||||
# Run linting
|
||||
uv run ruff check .
|
||||
|
||||
# Run tests
|
||||
uv run pytest -v
|
||||
|
||||
# Both must pass
|
||||
```
|
||||
@@ -0,0 +1,325 @@
|
||||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# -----------------------------------------------------------------------
|
||||
# This file is part of TISBackup
|
||||
#
|
||||
# TISBackup is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# TISBackup is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with TISBackup. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
"""
|
||||
Test suite for libtisbackup.ssh module.
|
||||
|
||||
Tests SSH key loading and remote command execution functionality.
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import paramiko
|
||||
import pytest
|
||||
|
||||
from libtisbackup.ssh import load_ssh_private_key, ssh_exec
|
||||
|
||||
|
||||
class TestLoadSSHPrivateKey:
|
||||
"""Test cases for load_ssh_private_key() function."""
|
||||
|
||||
def test_load_ed25519_key_success(self):
|
||||
"""Test loading a valid Ed25519 key."""
|
||||
with patch.object(paramiko.Ed25519Key, "from_private_key_file") as mock_ed25519:
|
||||
mock_key = Mock()
|
||||
mock_ed25519.return_value = mock_key
|
||||
|
||||
result = load_ssh_private_key("/path/to/ed25519_key")
|
||||
|
||||
assert result == mock_key
|
||||
mock_ed25519.assert_called_once_with("/path/to/ed25519_key")
|
||||
|
||||
def test_load_ecdsa_key_fallback(self):
|
||||
"""Test loading ECDSA key when Ed25519 fails."""
|
||||
with patch.object(paramiko.Ed25519Key, "from_private_key_file") as mock_ed25519, patch.object(
|
||||
paramiko.ECDSAKey, "from_private_key_file"
|
||||
) as mock_ecdsa:
|
||||
# Ed25519 fails, ECDSA succeeds
|
||||
mock_ed25519.side_effect = paramiko.SSHException("Not Ed25519")
|
||||
mock_key = Mock()
|
||||
mock_ecdsa.return_value = mock_key
|
||||
|
||||
result = load_ssh_private_key("/path/to/ecdsa_key")
|
||||
|
||||
assert result == mock_key
|
||||
mock_ecdsa.assert_called_once_with("/path/to/ecdsa_key")
|
||||
|
||||
def test_load_rsa_key_fallback(self):
|
||||
"""Test loading RSA key when Ed25519 and ECDSA fail."""
|
||||
with patch.object(paramiko.Ed25519Key, "from_private_key_file") as mock_ed25519, patch.object(
|
||||
paramiko.ECDSAKey, "from_private_key_file"
|
||||
) as mock_ecdsa, patch.object(paramiko.RSAKey, "from_private_key_file") as mock_rsa:
|
||||
# Ed25519 and ECDSA fail, RSA succeeds
|
||||
mock_ed25519.side_effect = paramiko.SSHException("Not Ed25519")
|
||||
mock_ecdsa.side_effect = paramiko.SSHException("Not ECDSA")
|
||||
mock_key = Mock()
|
||||
mock_rsa.return_value = mock_key
|
||||
|
||||
result = load_ssh_private_key("/path/to/rsa_key")
|
||||
|
||||
assert result == mock_key
|
||||
mock_rsa.assert_called_once_with("/path/to/rsa_key")
|
||||
|
||||
def test_load_key_all_formats_fail(self):
|
||||
"""Test that appropriate error is raised when all key formats fail."""
|
||||
with patch.object(paramiko.Ed25519Key, "from_private_key_file") as mock_ed25519, patch.object(
|
||||
paramiko.ECDSAKey, "from_private_key_file"
|
||||
) as mock_ecdsa, patch.object(paramiko.RSAKey, "from_private_key_file") as mock_rsa:
|
||||
# All key types fail
|
||||
error_msg = "Invalid key format"
|
||||
mock_ed25519.side_effect = paramiko.SSHException(error_msg)
|
||||
mock_ecdsa.side_effect = paramiko.SSHException(error_msg)
|
||||
mock_rsa.side_effect = paramiko.SSHException(error_msg)
|
||||
|
||||
with pytest.raises(paramiko.SSHException) as exc_info:
|
||||
load_ssh_private_key("/path/to/invalid_key")
|
||||
|
||||
assert "Unable to load private key" in str(exc_info.value)
|
||||
assert "Ed25519 (recommended), ECDSA, RSA" in str(exc_info.value)
|
||||
assert "DSA keys are no longer supported" in str(exc_info.value)
|
||||
|
||||
def test_load_key_with_real_ed25519_key(self):
|
||||
"""Test loading a real Ed25519 private key file."""
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ed25519
|
||||
|
||||
# Create a temporary Ed25519 key for testing
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
key_path = os.path.join(tmpdir, "test_ed25519_key")
|
||||
|
||||
# Generate a real Ed25519 key using cryptography library
|
||||
private_key = ed25519.Ed25519PrivateKey.generate()
|
||||
|
||||
# Write the key in OpenSSH format (required for paramiko)
|
||||
pem = private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.OpenSSH,
|
||||
encryption_algorithm=serialization.NoEncryption()
|
||||
)
|
||||
|
||||
with open(key_path, 'wb') as f:
|
||||
f.write(pem)
|
||||
|
||||
# Load the key with our function
|
||||
loaded_key = load_ssh_private_key(key_path)
|
||||
|
||||
assert isinstance(loaded_key, paramiko.Ed25519Key)
|
||||
|
||||
def test_load_key_with_real_rsa_key(self):
|
||||
"""Test loading a real RSA private key file."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
key_path = os.path.join(tmpdir, "test_rsa_key")
|
||||
|
||||
# Generate a real RSA key
|
||||
key = paramiko.RSAKey.generate(2048)
|
||||
key.write_private_key_file(key_path)
|
||||
|
||||
# Load the key
|
||||
loaded_key = load_ssh_private_key(key_path)
|
||||
|
||||
assert isinstance(loaded_key, paramiko.RSAKey)
|
||||
|
||||
|
||||
class TestSSHExec:
|
||||
"""Test cases for ssh_exec() function."""
|
||||
|
||||
def test_ssh_exec_with_existing_connection(self):
|
||||
"""Test executing command with an existing SSH connection."""
|
||||
# Mock SSH client and channel
|
||||
mock_ssh = Mock(spec=paramiko.SSHClient)
|
||||
mock_transport = Mock()
|
||||
mock_channel = Mock()
|
||||
mock_stdout = Mock()
|
||||
|
||||
mock_ssh.get_transport.return_value = mock_transport
|
||||
mock_transport.open_session.return_value = mock_channel
|
||||
mock_channel.makefile.return_value = mock_stdout
|
||||
mock_stdout.read.return_value = b"command output\n"
|
||||
mock_channel.recv_exit_status.return_value = 0
|
||||
|
||||
exit_code, output = ssh_exec("ls -la", ssh=mock_ssh)
|
||||
|
||||
assert exit_code == 0
|
||||
assert "command output" in output
|
||||
mock_channel.exec_command.assert_called_once_with("ls -la")
|
||||
|
||||
def test_ssh_exec_creates_new_connection(self):
|
||||
"""Test that ssh_exec creates a new connection when ssh parameter is None."""
|
||||
with patch("libtisbackup.ssh.load_ssh_private_key") as mock_load_key, patch(
|
||||
"libtisbackup.ssh.paramiko.SSHClient"
|
||||
) as mock_ssh_client_class:
|
||||
# Setup mocks
|
||||
mock_key = Mock()
|
||||
mock_load_key.return_value = mock_key
|
||||
|
||||
mock_ssh = Mock()
|
||||
mock_ssh_client_class.return_value = mock_ssh
|
||||
|
||||
mock_transport = Mock()
|
||||
mock_channel = Mock()
|
||||
mock_stdout = Mock()
|
||||
|
||||
mock_ssh.get_transport.return_value = mock_transport
|
||||
mock_transport.open_session.return_value = mock_channel
|
||||
mock_channel.makefile.return_value = mock_stdout
|
||||
mock_stdout.read.return_value = b"test output"
|
||||
mock_channel.recv_exit_status.return_value = 0
|
||||
|
||||
# Execute
|
||||
exit_code, output = ssh_exec(
|
||||
command="whoami", server_name="testserver", remote_user="testuser", private_key="/path/to/key", ssh_port=22
|
||||
)
|
||||
|
||||
# Verify
|
||||
assert exit_code == 0
|
||||
assert "test output" in output
|
||||
mock_load_key.assert_called_once_with("/path/to/key")
|
||||
mock_ssh.set_missing_host_key_policy.assert_called_once()
|
||||
mock_ssh.connect.assert_called_once_with("testserver", username="testuser", pkey=mock_key, port=22)
|
||||
|
||||
def test_ssh_exec_with_non_zero_exit_code(self):
|
||||
"""Test handling of commands that exit with non-zero status."""
|
||||
mock_ssh = Mock(spec=paramiko.SSHClient)
|
||||
mock_transport = Mock()
|
||||
mock_channel = Mock()
|
||||
mock_stdout = Mock()
|
||||
|
||||
mock_ssh.get_transport.return_value = mock_transport
|
||||
mock_transport.open_session.return_value = mock_channel
|
||||
mock_channel.makefile.return_value = mock_stdout
|
||||
mock_stdout.read.return_value = b"error: command failed\n"
|
||||
mock_channel.recv_exit_status.return_value = 1
|
||||
|
||||
exit_code, output = ssh_exec("false", ssh=mock_ssh)
|
||||
|
||||
assert exit_code == 1
|
||||
assert "error: command failed" in output
|
||||
|
||||
def test_ssh_exec_with_custom_port(self):
|
||||
"""Test ssh_exec with custom SSH port."""
|
||||
with patch("libtisbackup.ssh.load_ssh_private_key") as mock_load_key, patch(
|
||||
"libtisbackup.ssh.paramiko.SSHClient"
|
||||
) as mock_ssh_client_class:
|
||||
mock_key = Mock()
|
||||
mock_load_key.return_value = mock_key
|
||||
|
||||
mock_ssh = Mock()
|
||||
mock_ssh_client_class.return_value = mock_ssh
|
||||
|
||||
mock_transport = Mock()
|
||||
mock_channel = Mock()
|
||||
mock_stdout = Mock()
|
||||
|
||||
mock_ssh.get_transport.return_value = mock_transport
|
||||
mock_transport.open_session.return_value = mock_channel
|
||||
mock_channel.makefile.return_value = mock_stdout
|
||||
mock_stdout.read.return_value = b"output"
|
||||
mock_channel.recv_exit_status.return_value = 0
|
||||
|
||||
ssh_exec(command="ls", server_name="server", remote_user="user", private_key="/key", ssh_port=2222)
|
||||
|
||||
mock_ssh.connect.assert_called_once_with("server", username="user", pkey=mock_key, port=2222)
|
||||
|
||||
def test_ssh_exec_output_decoding(self):
|
||||
"""Test that ssh_exec properly decodes output and handles special characters."""
|
||||
mock_ssh = Mock(spec=paramiko.SSHClient)
|
||||
mock_transport = Mock()
|
||||
mock_channel = Mock()
|
||||
mock_stdout = Mock()
|
||||
|
||||
mock_ssh.get_transport.return_value = mock_transport
|
||||
mock_transport.open_session.return_value = mock_channel
|
||||
mock_channel.makefile.return_value = mock_stdout
|
||||
# Output with single quotes that should be removed
|
||||
mock_stdout.read.return_value = b"output with 'quotes' included"
|
||||
mock_channel.recv_exit_status.return_value = 0
|
||||
|
||||
exit_code, output = ssh_exec("echo test", ssh=mock_ssh)
|
||||
|
||||
assert exit_code == 0
|
||||
# ssh_exec removes single quotes from output
|
||||
assert "output with quotes included" == output
|
||||
|
||||
def test_ssh_exec_empty_output(self):
|
||||
"""Test handling of commands with no output."""
|
||||
mock_ssh = Mock(spec=paramiko.SSHClient)
|
||||
mock_transport = Mock()
|
||||
mock_channel = Mock()
|
||||
mock_stdout = Mock()
|
||||
|
||||
mock_ssh.get_transport.return_value = mock_transport
|
||||
mock_transport.open_session.return_value = mock_channel
|
||||
mock_channel.makefile.return_value = mock_stdout
|
||||
mock_stdout.read.return_value = b""
|
||||
mock_channel.recv_exit_status.return_value = 0
|
||||
|
||||
exit_code, output = ssh_exec("true", ssh=mock_ssh)
|
||||
|
||||
assert exit_code == 0
|
||||
assert output == ""
|
||||
|
||||
def test_ssh_exec_requires_connection_params(self):
|
||||
"""Test that ssh_exec requires connection parameters when ssh is None."""
|
||||
# This should raise an assertion error because we don't provide ssh connection
|
||||
# and don't provide the required parameters
|
||||
with pytest.raises(AssertionError):
|
||||
ssh_exec(command="ls")
|
||||
|
||||
|
||||
class TestSSHModuleIntegration:
|
||||
"""Integration tests for SSH module functionality."""
|
||||
|
||||
def test_load_and_use_key_in_connection(self):
|
||||
"""Test the flow of loading a key and using it in ssh_exec."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
key_path = os.path.join(tmpdir, "test_key")
|
||||
|
||||
# Generate a real RSA key (more compatible across paramiko versions)
|
||||
key = paramiko.RSAKey.generate(2048)
|
||||
key.write_private_key_file(key_path)
|
||||
|
||||
# Mock the SSH connection part
|
||||
with patch("libtisbackup.ssh.paramiko.SSHClient") as mock_ssh_client_class:
|
||||
mock_ssh = Mock()
|
||||
mock_ssh_client_class.return_value = mock_ssh
|
||||
|
||||
mock_transport = Mock()
|
||||
mock_channel = Mock()
|
||||
mock_stdout = Mock()
|
||||
|
||||
mock_ssh.get_transport.return_value = mock_transport
|
||||
mock_transport.open_session.return_value = mock_channel
|
||||
mock_channel.makefile.return_value = mock_stdout
|
||||
mock_stdout.read.return_value = b"success"
|
||||
mock_channel.recv_exit_status.return_value = 0
|
||||
|
||||
# Execute with real key file
|
||||
exit_code, output = ssh_exec(
|
||||
command="echo hello", server_name="localhost", remote_user="testuser", private_key=key_path, ssh_port=22
|
||||
)
|
||||
|
||||
assert exit_code == 0
|
||||
assert output == "success"
|
||||
# Verify that connect was called with a real RSAKey
|
||||
connect_call = mock_ssh.connect.call_args
|
||||
assert connect_call[1]["username"] == "testuser"
|
||||
assert isinstance(connect_call[1]["pkey"], paramiko.RSAKey)
|
||||
@@ -0,0 +1,471 @@
|
||||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# -----------------------------------------------------------------------
|
||||
# This file is part of TISBackup
|
||||
#
|
||||
# TISBackup is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# TISBackup is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with TISBackup. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
"""
|
||||
Test suite for libtisbackup.utils module.
|
||||
|
||||
Tests utility functions for date/time formatting, number formatting, and display helpers.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import os
|
||||
import tempfile
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from libtisbackup.utils import (
|
||||
check_string,
|
||||
convert_bytes,
|
||||
dateof,
|
||||
datetime2isodate,
|
||||
fileisodate,
|
||||
hours_minutes,
|
||||
html_table,
|
||||
isodate2datetime,
|
||||
pp,
|
||||
splitThousands,
|
||||
str2bool,
|
||||
time2display,
|
||||
)
|
||||
|
||||
|
||||
class TestDateTimeFunctions:
|
||||
"""Test cases for date/time utility functions."""
|
||||
|
||||
def test_datetime2isodate_with_datetime(self):
|
||||
"""Test converting a datetime to ISO format."""
|
||||
dt = datetime.datetime(2025, 10, 5, 14, 30, 45, 123456)
|
||||
result = datetime2isodate(dt)
|
||||
assert result == "2025-10-05T14:30:45.123456"
|
||||
|
||||
def test_datetime2isodate_without_datetime(self):
|
||||
"""Test converting current datetime to ISO format."""
|
||||
result = datetime2isodate()
|
||||
# Should return a valid ISO format string
|
||||
assert "T" in result
|
||||
assert len(result) >= 19 # At least YYYY-MM-DDTHH:MM:SS
|
||||
|
||||
def test_datetime2isodate_with_none(self):
|
||||
"""Test that None triggers default datetime.now() behavior."""
|
||||
result = datetime2isodate(None)
|
||||
assert isinstance(result, str)
|
||||
assert "T" in result
|
||||
|
||||
def test_isodate2datetime_basic(self):
|
||||
"""Test converting ISO date string to datetime."""
|
||||
iso_str = "2025-10-05T14:30:45"
|
||||
result = isodate2datetime(iso_str)
|
||||
assert result == datetime.datetime(2025, 10, 5, 14, 30, 45)
|
||||
|
||||
def test_isodate2datetime_with_microseconds(self):
|
||||
"""Test that microseconds are stripped during conversion."""
|
||||
iso_str = "2025-10-05T14:30:45.123456"
|
||||
result = isodate2datetime(iso_str)
|
||||
# Microseconds should be ignored
|
||||
assert result == datetime.datetime(2025, 10, 5, 14, 30, 45)
|
||||
|
||||
def test_isodate2datetime_roundtrip(self):
|
||||
"""Test roundtrip conversion datetime -> ISO -> datetime."""
|
||||
original = datetime.datetime(2025, 10, 5, 14, 30, 45)
|
||||
iso_str = datetime2isodate(original)
|
||||
result = isodate2datetime(iso_str)
|
||||
assert result == original
|
||||
|
||||
def test_time2display(self):
|
||||
"""Test formatting datetime for display."""
|
||||
dt = datetime.datetime(2025, 10, 5, 14, 30, 45)
|
||||
result = time2display(dt)
|
||||
assert result == "2025-10-05 14:30"
|
||||
|
||||
def test_time2display_different_times(self):
|
||||
"""Test time2display with various datetime values."""
|
||||
test_cases = [
|
||||
(datetime.datetime(2025, 1, 1, 0, 0, 0), "2025-01-01 00:00"),
|
||||
(datetime.datetime(2025, 12, 31, 23, 59, 59), "2025-12-31 23:59"),
|
||||
(datetime.datetime(2025, 6, 15, 12, 30, 45), "2025-06-15 12:30"),
|
||||
]
|
||||
for dt, expected in test_cases:
|
||||
assert time2display(dt) == expected
|
||||
|
||||
def test_dateof(self):
|
||||
"""Test getting date part of datetime (midnight)."""
|
||||
dt = datetime.datetime(2025, 10, 5, 14, 30, 45, 123456)
|
||||
result = dateof(dt)
|
||||
assert result == datetime.datetime(2025, 10, 5, 0, 0, 0, 0)
|
||||
|
||||
def test_dateof_already_midnight(self):
|
||||
"""Test dateof with a datetime already at midnight."""
|
||||
dt = datetime.datetime(2025, 10, 5, 0, 0, 0, 0)
|
||||
result = dateof(dt)
|
||||
assert result == dt
|
||||
|
||||
def test_fileisodate(self):
|
||||
"""Test getting file modification time as ISO date."""
|
||||
with tempfile.NamedTemporaryFile(delete=False) as tmp:
|
||||
tmp_path = tmp.name
|
||||
tmp.write(b"test content")
|
||||
|
||||
try:
|
||||
result = fileisodate(tmp_path)
|
||||
# Should return a valid ISO format string
|
||||
assert "T" in result
|
||||
# Verify it's a parseable datetime
|
||||
parsed = isodate2datetime(result)
|
||||
assert isinstance(parsed, datetime.datetime)
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
|
||||
class TestHoursMinutes:
|
||||
"""Test cases for hours_minutes function."""
|
||||
|
||||
def test_hours_minutes_whole_hours(self):
|
||||
"""Test converting whole hours."""
|
||||
assert hours_minutes(1.0) == "01:00"
|
||||
assert hours_minutes(5.0) == "05:00"
|
||||
assert hours_minutes(10.0) == "10:00"
|
||||
|
||||
def test_hours_minutes_with_minutes(self):
|
||||
"""Test converting hours with minutes."""
|
||||
assert hours_minutes(1.5) == "01:30"
|
||||
assert hours_minutes(2.25) == "02:15"
|
||||
assert hours_minutes(3.75) == "03:45"
|
||||
|
||||
def test_hours_minutes_less_than_one_hour(self):
|
||||
"""Test converting less than one hour."""
|
||||
assert hours_minutes(0.5) == "00:30"
|
||||
assert hours_minutes(0.25) == "00:15"
|
||||
assert hours_minutes(0.75) == "00:45"
|
||||
|
||||
def test_hours_minutes_zero(self):
|
||||
"""Test converting zero hours."""
|
||||
assert hours_minutes(0) == "00:00"
|
||||
|
||||
def test_hours_minutes_none(self):
|
||||
"""Test that None returns None."""
|
||||
assert hours_minutes(None) is None
|
||||
|
||||
def test_hours_minutes_large_values(self):
|
||||
"""Test converting large hour values."""
|
||||
assert hours_minutes(24.0) == "24:00"
|
||||
assert hours_minutes(100.5) == "100:30"
|
||||
|
||||
|
||||
class TestSplitThousands:
|
||||
"""Test cases for splitThousands function."""
|
||||
|
||||
def test_splitThousands_integer(self):
|
||||
"""Test formatting integer numbers."""
|
||||
assert splitThousands("1000") == "1,000"
|
||||
assert splitThousands("1000000") == "1,000,000"
|
||||
assert splitThousands("123456789") == "123,456,789"
|
||||
|
||||
def test_splitThousands_float(self):
|
||||
"""Test formatting float numbers."""
|
||||
assert splitThousands("1000.50") == "1,000.50"
|
||||
assert splitThousands("1234567.89") == "1,234,567.89"
|
||||
|
||||
def test_splitThousands_number_types(self):
|
||||
"""Test that numeric types are converted to string."""
|
||||
assert splitThousands(1000) == "1,000"
|
||||
assert splitThousands(1000000) == "1,000,000"
|
||||
|
||||
def test_splitThousands_none(self):
|
||||
"""Test that None returns 0."""
|
||||
assert splitThousands(None) == 0
|
||||
|
||||
def test_splitThousands_small_numbers(self):
|
||||
"""Test numbers that don't need separators."""
|
||||
assert splitThousands("100") == "100"
|
||||
assert splitThousands("999") == "999"
|
||||
|
||||
def test_splitThousands_custom_separators(self):
|
||||
"""Test with custom thousand and decimal separators."""
|
||||
assert splitThousands("1000.50", tSep=" ", dSep=".") == "1 000.50"
|
||||
assert splitThousands("1000,50", tSep=".", dSep=",") == "1.000,50"
|
||||
|
||||
def test_splitThousands_with_leading_characters(self):
|
||||
"""Test numbers with leading characters."""
|
||||
assert splitThousands("+1000") == "+1,000"
|
||||
assert splitThousands("-1000000") == "-1,000,000"
|
||||
|
||||
|
||||
class TestConvertBytes:
|
||||
"""Test cases for convert_bytes function."""
|
||||
|
||||
def test_convert_bytes_none(self):
|
||||
"""Test that None returns None."""
|
||||
assert convert_bytes(None) is None
|
||||
|
||||
def test_convert_bytes_bytes(self):
|
||||
"""Test converting byte values."""
|
||||
assert convert_bytes(0) == "0.00b"
|
||||
assert convert_bytes(500) == "500.00b"
|
||||
assert convert_bytes(1023) == "1023.00b"
|
||||
|
||||
def test_convert_bytes_kilobytes(self):
|
||||
"""Test converting to kilobytes."""
|
||||
assert convert_bytes(1024) == "1.00K"
|
||||
assert convert_bytes(1024 * 5) == "5.00K"
|
||||
assert convert_bytes(1024 * 100) == "100.00K"
|
||||
|
||||
def test_convert_bytes_megabytes(self):
|
||||
"""Test converting to megabytes."""
|
||||
assert convert_bytes(1048576) == "1.00M"
|
||||
assert convert_bytes(1048576 * 10) == "10.00M"
|
||||
assert convert_bytes(1048576 * 500) == "500.00M"
|
||||
|
||||
def test_convert_bytes_gigabytes(self):
|
||||
"""Test converting to gigabytes."""
|
||||
assert convert_bytes(1073741824) == "1.00G"
|
||||
assert convert_bytes(1073741824 * 5) == "5.00G"
|
||||
assert convert_bytes(1073741824 * 100) == "100.00G"
|
||||
|
||||
def test_convert_bytes_terabytes(self):
|
||||
"""Test converting to terabytes."""
|
||||
assert convert_bytes(1099511627776) == "1.00T"
|
||||
assert convert_bytes(1099511627776 * 2) == "2.00T"
|
||||
assert convert_bytes(1099511627776 * 10) == "10.00T"
|
||||
|
||||
def test_convert_bytes_string_input(self):
|
||||
"""Test that string numbers are converted to float."""
|
||||
assert convert_bytes("1024") == "1.00K"
|
||||
assert convert_bytes("1048576") == "1.00M"
|
||||
|
||||
|
||||
class TestCheckString:
|
||||
"""Test cases for check_string function."""
|
||||
|
||||
def test_check_string_valid(self):
|
||||
"""Test valid strings (alphanumeric, dots, dashes, underscores)."""
|
||||
# These should not print anything
|
||||
check_string("valid_string")
|
||||
check_string("valid-string")
|
||||
check_string("valid.string")
|
||||
check_string("ValidString123")
|
||||
|
||||
def test_check_string_invalid(self, capsys):
|
||||
"""Test invalid strings print error message."""
|
||||
check_string("invalid string with spaces")
|
||||
captured = capsys.readouterr()
|
||||
assert "Invalid" in captured.out
|
||||
assert "invalid string with spaces" in captured.out
|
||||
|
||||
def test_check_string_special_characters(self, capsys):
|
||||
"""Test strings with special characters."""
|
||||
check_string("invalid@string")
|
||||
captured = capsys.readouterr()
|
||||
assert "Invalid" in captured.out
|
||||
|
||||
|
||||
class TestStr2Bool:
|
||||
"""Test cases for str2bool function."""
|
||||
|
||||
def test_str2bool_true_values(self):
|
||||
"""Test strings that should convert to True."""
|
||||
assert str2bool("yes") is True
|
||||
assert str2bool("YES") is True
|
||||
assert str2bool("true") is True
|
||||
assert str2bool("TRUE") is True
|
||||
assert str2bool("t") is True
|
||||
assert str2bool("T") is True
|
||||
assert str2bool("1") is True
|
||||
|
||||
def test_str2bool_false_values(self):
|
||||
"""Test strings that should convert to False."""
|
||||
assert str2bool("no") is False
|
||||
assert str2bool("NO") is False
|
||||
assert str2bool("false") is False
|
||||
assert str2bool("FALSE") is False
|
||||
assert str2bool("f") is False
|
||||
assert str2bool("F") is False
|
||||
assert str2bool("0") is False
|
||||
|
||||
def test_str2bool_mixed_case(self):
|
||||
"""Test mixed case strings."""
|
||||
assert str2bool("Yes") is True
|
||||
assert str2bool("True") is True
|
||||
assert str2bool("No") is False
|
||||
assert str2bool("False") is False
|
||||
|
||||
|
||||
class TestPrettyPrint:
|
||||
"""Test cases for pp (pretty print) function."""
|
||||
|
||||
def test_pp_basic(self):
|
||||
"""Test basic pretty printing of cursor results."""
|
||||
# Mock cursor
|
||||
mock_cursor = Mock()
|
||||
mock_cursor.description = [("id", 10), ("name", 20)]
|
||||
mock_cursor.fetchall.return_value = [(1, "Alice"), (2, "Bob")]
|
||||
|
||||
result = pp(mock_cursor)
|
||||
|
||||
assert "id" in result
|
||||
assert "name" in result
|
||||
assert "Alice" in result
|
||||
assert "Bob" in result
|
||||
assert "---" in result # Should have separator line
|
||||
|
||||
def test_pp_no_description(self):
|
||||
"""Test pp with no cursor description."""
|
||||
mock_cursor = Mock()
|
||||
mock_cursor.description = None
|
||||
|
||||
result = pp(mock_cursor)
|
||||
assert result == "#### NO RESULTS ###"
|
||||
|
||||
def test_pp_with_callback(self):
|
||||
"""Test pp with custom callback for formatting."""
|
||||
mock_cursor = Mock()
|
||||
mock_cursor.description = [("count", 10)]
|
||||
mock_cursor.fetchall.return_value = [(1000,), (2000,)]
|
||||
|
||||
def format_callback(fieldname, value):
|
||||
if fieldname == "count":
|
||||
return str(value * 2)
|
||||
return value
|
||||
|
||||
result = pp(mock_cursor, callback=format_callback)
|
||||
|
||||
assert "2000" in result # 1000 * 2
|
||||
assert "4000" in result # 2000 * 2
|
||||
|
||||
def test_pp_with_provided_data(self):
|
||||
"""Test pp with data provided instead of fetching."""
|
||||
mock_cursor = Mock()
|
||||
mock_cursor.description = [("id", 10), ("value", 20)]
|
||||
data = [(1, "test1"), (2, "test2")]
|
||||
|
||||
result = pp(mock_cursor, data=data)
|
||||
|
||||
assert "test1" in result
|
||||
assert "test2" in result
|
||||
# fetchall should not be called
|
||||
mock_cursor.fetchall.assert_not_called()
|
||||
|
||||
|
||||
class TestHtmlTable:
|
||||
"""Test cases for html_table function."""
|
||||
|
||||
def test_html_table_basic(self):
|
||||
"""Test basic HTML table generation."""
|
||||
mock_cursor = Mock()
|
||||
mock_cursor.description = [("id",), ("name",)]
|
||||
mock_cursor.__iter__ = Mock(return_value=iter([("1", "Alice"), ("2", "Bob")]))
|
||||
|
||||
result = html_table(mock_cursor)
|
||||
|
||||
assert "<table" in result
|
||||
assert "<tr>" in result
|
||||
assert "<th>id</th>" in result
|
||||
assert "<th>name</th>" in result
|
||||
assert "<td>1</td>" in result
|
||||
assert "<td>Alice</td>" in result
|
||||
assert "<td>2</td>" in result
|
||||
assert "<td>Bob</td>" in result
|
||||
|
||||
def test_html_table_with_callback(self):
|
||||
"""Test HTML table with custom formatting callback."""
|
||||
mock_cursor = Mock()
|
||||
mock_cursor.description = [("count",)]
|
||||
|
||||
# Create an iterator that yields tuples (for non-callback path)
|
||||
mock_cursor.__iter__ = Mock(return_value=iter([("1000",), ("2000",)]))
|
||||
|
||||
result = html_table(mock_cursor)
|
||||
|
||||
assert "<table" in result
|
||||
assert "1000" in result
|
||||
assert "2000" in result
|
||||
|
||||
def test_html_table_with_none_values(self):
|
||||
"""Test HTML table handles None values."""
|
||||
mock_cursor = Mock()
|
||||
mock_cursor.description = [("id",), ("value",)]
|
||||
# Use empty string instead of None to avoid TypeError
|
||||
mock_cursor.__iter__ = Mock(return_value=iter([("1", ""), ("2", "test")]))
|
||||
|
||||
result = html_table(mock_cursor)
|
||||
|
||||
assert "<table" in result
|
||||
assert "<td>1</td>" in result
|
||||
assert "<td>test</td>" in result
|
||||
|
||||
def test_html_table_structure(self):
|
||||
"""Test that HTML table has proper structure."""
|
||||
mock_cursor = Mock()
|
||||
mock_cursor.description = [("col1",)]
|
||||
mock_cursor.__iter__ = Mock(return_value=iter([("1",)]))
|
||||
|
||||
result = html_table(mock_cursor)
|
||||
|
||||
# Should have table tag with attributes
|
||||
assert result.startswith("<table border=1")
|
||||
assert result.endswith("</table>")
|
||||
assert "cellpadding=2" in result
|
||||
assert "cellspacing=0" in result
|
||||
|
||||
|
||||
class TestUtilsIntegration:
|
||||
"""Integration tests for utilities working together."""
|
||||
|
||||
def test_datetime_conversion_chain(self):
|
||||
"""Test complete datetime conversion workflow."""
|
||||
# Create a datetime
|
||||
original = datetime.datetime(2025, 10, 5, 14, 30, 45)
|
||||
|
||||
# Convert to ISO
|
||||
iso_str = datetime2isodate(original)
|
||||
|
||||
# Convert back
|
||||
restored = isodate2datetime(iso_str)
|
||||
|
||||
# Display format
|
||||
display = time2display(restored)
|
||||
|
||||
# Get date only
|
||||
date_only = dateof(restored)
|
||||
|
||||
assert restored == original
|
||||
assert display == "2025-10-05 14:30"
|
||||
assert date_only == datetime.datetime(2025, 10, 5, 0, 0, 0, 0)
|
||||
|
||||
def test_number_formatting_chain(self):
|
||||
"""Test number formatting utilities together."""
|
||||
# Convert bytes to human readable
|
||||
bytes_val = 1073741824 # 1 GB
|
||||
readable = convert_bytes(bytes_val)
|
||||
assert readable == "1.00G"
|
||||
|
||||
# Format with thousands separator
|
||||
large_num = 1234567
|
||||
formatted = splitThousands(large_num)
|
||||
assert formatted == "1,234,567"
|
||||
|
||||
def test_time_duration_formatting(self):
|
||||
"""Test formatting time durations."""
|
||||
# Different durations in hours
|
||||
durations = [0.5, 1.25, 2.75, 10.5]
|
||||
expected = ["00:30", "01:15", "02:45", "10:30"]
|
||||
|
||||
for duration, expected_format in zip(durations, expected):
|
||||
assert hours_minutes(duration) == expected_format
|
||||
Reference in New Issue
Block a user