Some checks failed
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>
472 lines
16 KiB
Python
472 lines
16 KiB
Python
#!/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
|