mirror of
https://github.com/hanzoskill/ouracli.git
synced 2026-10-30 21:03:32 +00:00
426 lines
16 KiB
Python
426 lines
16 KiB
Python
"""Fuzzing tests for CLI inputs and API responses."""
|
|
|
|
import json
|
|
from datetime import datetime
|
|
|
|
import pytest
|
|
from hypothesis import given, settings
|
|
from hypothesis import strategies as st
|
|
|
|
from ouracli.charts_html import create_chartjs_config, create_chartjs_heartrate_config
|
|
from ouracli.charts_mermaid import create_mermaid_bar_chart, create_mermaid_heartrate_chart
|
|
from ouracli.date_parser import parse_date_range
|
|
from ouracli.formatters import format_output
|
|
|
|
|
|
class TestDateParserFuzzing:
|
|
"""Fuzzing tests for date parser."""
|
|
|
|
@given(st.text(min_size=0, max_size=100))
|
|
@settings(max_examples=200)
|
|
def test_date_parser_handles_arbitrary_strings(self, date_string: str) -> None:
|
|
"""Test that date parser doesn't crash on arbitrary strings."""
|
|
# Should either return valid date strings or raise ValueError
|
|
try:
|
|
start, end = parse_date_range(date_string)
|
|
# If it succeeds, validate the result
|
|
assert isinstance(start, str)
|
|
assert isinstance(end, str)
|
|
# Should be valid date format
|
|
datetime.strptime(start, "%Y-%m-%d")
|
|
datetime.strptime(end, "%Y-%m-%d")
|
|
except ValueError:
|
|
# Expected for invalid inputs
|
|
pass
|
|
|
|
@given(st.integers(min_value=-1000, max_value=1000))
|
|
@settings(max_examples=100)
|
|
def test_date_parser_with_numeric_strings(self, num: int) -> None:
|
|
"""Test date parser with numeric strings."""
|
|
try:
|
|
start, end = parse_date_range(str(num))
|
|
assert isinstance(start, str)
|
|
assert isinstance(end, str)
|
|
except ValueError:
|
|
pass
|
|
|
|
@given(st.text(alphabet="0123456789-T:Z.", min_size=1, max_size=50))
|
|
@settings(max_examples=100)
|
|
def test_date_parser_with_date_like_strings(self, date_str: str) -> None:
|
|
"""Test date parser with date-like strings."""
|
|
try:
|
|
start, end = parse_date_range(date_str)
|
|
assert isinstance(start, str)
|
|
assert isinstance(end, str)
|
|
except ValueError:
|
|
pass
|
|
|
|
@given(
|
|
st.integers(min_value=1, max_value=365),
|
|
st.sampled_from(["day", "days", "week", "weeks", "month", "months"]),
|
|
)
|
|
@settings(max_examples=50)
|
|
def test_date_parser_with_relative_dates(self, num: int, unit: str) -> None:
|
|
"""Test date parser with relative date strings."""
|
|
date_str = f"{num} {unit}"
|
|
try:
|
|
start, end = parse_date_range(date_str)
|
|
assert isinstance(start, str)
|
|
assert isinstance(end, str)
|
|
# Validate date strings
|
|
start_dt = datetime.strptime(start, "%Y-%m-%d")
|
|
end_dt = datetime.strptime(end, "%Y-%m-%d")
|
|
assert start_dt <= end_dt
|
|
except ValueError:
|
|
pass
|
|
|
|
|
|
class TestFormattersFuzzing:
|
|
"""Fuzzing tests for formatters."""
|
|
|
|
@given(
|
|
st.dictionaries(
|
|
keys=st.text(min_size=1, max_size=50),
|
|
values=st.one_of(
|
|
st.none(),
|
|
st.booleans(),
|
|
st.integers(),
|
|
st.floats(allow_nan=False, allow_infinity=False),
|
|
st.text(max_size=100),
|
|
),
|
|
max_size=20,
|
|
),
|
|
st.sampled_from(["json", "tree", "markdown", "html"]),
|
|
)
|
|
@settings(max_examples=100)
|
|
def test_format_output_handles_arbitrary_dicts(self, data: dict, format_type: str) -> None:
|
|
"""Test that formatters handle arbitrary dictionary data."""
|
|
try:
|
|
result = format_output(data, format_type)
|
|
assert isinstance(result, str)
|
|
# Output should not be empty for non-empty input
|
|
if data:
|
|
assert len(result) > 0
|
|
except (ValueError, TypeError, KeyError):
|
|
# Some exceptions are acceptable (e.g., for invalid data)
|
|
pass
|
|
|
|
@given(
|
|
st.lists(
|
|
st.dictionaries(
|
|
keys=st.text(min_size=1, max_size=20),
|
|
values=st.one_of(
|
|
st.none(), st.integers(), st.floats(allow_nan=False), st.text(max_size=50)
|
|
),
|
|
max_size=10,
|
|
),
|
|
max_size=10,
|
|
),
|
|
st.sampled_from(["json", "tree", "markdown"]),
|
|
)
|
|
@settings(max_examples=50)
|
|
def test_format_output_handles_list_of_dicts(self, data: list, format_type: str) -> None:
|
|
"""Test formatters with lists of dictionaries."""
|
|
try:
|
|
result = format_output(data, format_type)
|
|
assert isinstance(result, str)
|
|
except (ValueError, TypeError):
|
|
pass
|
|
|
|
|
|
class TestChartsHtmlFuzzing:
|
|
"""Fuzzing tests for HTML chart generation."""
|
|
|
|
@given(
|
|
st.lists(
|
|
st.floats(min_value=0, max_value=10, allow_nan=False, allow_infinity=False),
|
|
max_size=2000,
|
|
)
|
|
)
|
|
@settings(max_examples=50)
|
|
def test_chartjs_config_handles_arbitrary_met_data(self, met_items: list) -> None:
|
|
"""Test Chart.js config generation with arbitrary MET data."""
|
|
try:
|
|
result = create_chartjs_config(met_items, "test_chart")
|
|
assert isinstance(result, str)
|
|
if met_items:
|
|
assert "new Chart" in result
|
|
assert "test_chart" in result
|
|
except Exception as e:
|
|
# Should not crash
|
|
pytest.fail(f"Unexpected exception: {e}")
|
|
|
|
@given(
|
|
st.lists(
|
|
st.fixed_dictionaries(
|
|
{
|
|
"timestamp": st.one_of(
|
|
st.datetimes(
|
|
min_value=datetime(2020, 1, 1), max_value=datetime(2025, 12, 31)
|
|
).map(lambda dt: dt.isoformat() + "Z"),
|
|
st.text(alphabet="0123456789-T:Z", max_size=30),
|
|
st.just(""),
|
|
),
|
|
"bpm": st.one_of(
|
|
st.none(),
|
|
st.integers(min_value=30, max_value=220),
|
|
st.floats(
|
|
min_value=30, max_value=220, allow_nan=False, allow_infinity=False
|
|
),
|
|
),
|
|
}
|
|
),
|
|
max_size=500,
|
|
)
|
|
)
|
|
@settings(max_examples=50)
|
|
def test_chartjs_heartrate_config_handles_arbitrary_data(self, heartrate_data: list) -> None:
|
|
"""Test Chart.js heartrate config with arbitrary heart rate data."""
|
|
try:
|
|
result = create_chartjs_heartrate_config(heartrate_data, "hr_chart")
|
|
assert isinstance(result, str)
|
|
# Empty result is OK for invalid data
|
|
except Exception as e:
|
|
pytest.fail(f"Unexpected exception: {e}")
|
|
|
|
@given(
|
|
st.lists(
|
|
st.floats(min_value=-100, max_value=100, allow_nan=False, allow_infinity=False),
|
|
max_size=1440,
|
|
)
|
|
)
|
|
@settings(max_examples=30)
|
|
def test_chartjs_config_handles_extreme_values(self, met_items: list) -> None:
|
|
"""Test Chart.js config with extreme values."""
|
|
result = create_chartjs_config(met_items, "extreme")
|
|
assert isinstance(result, str)
|
|
assert "new Chart" in result
|
|
|
|
|
|
class TestChartsMermaidFuzzing:
|
|
"""Fuzzing tests for Mermaid chart generation."""
|
|
|
|
@given(
|
|
st.lists(
|
|
st.floats(min_value=0, max_value=10, allow_nan=False, allow_infinity=False),
|
|
max_size=2000,
|
|
)
|
|
)
|
|
@settings(max_examples=50)
|
|
def test_mermaid_bar_chart_handles_arbitrary_data(self, met_items: list) -> None:
|
|
"""Test Mermaid bar chart with arbitrary MET data."""
|
|
try:
|
|
result = create_mermaid_bar_chart(met_items)
|
|
assert isinstance(result, str)
|
|
# Should always produce valid output
|
|
if met_items:
|
|
assert "```mermaid" in result
|
|
except Exception as e:
|
|
pytest.fail(f"Unexpected exception: {e}")
|
|
|
|
@given(
|
|
st.lists(
|
|
st.fixed_dictionaries(
|
|
{
|
|
"timestamp": st.datetimes(
|
|
min_value=datetime(2020, 1, 1), max_value=datetime(2025, 12, 31)
|
|
).map(lambda dt: dt.isoformat() + "Z"),
|
|
"bpm": st.one_of(
|
|
st.none(),
|
|
st.integers(min_value=30, max_value=220),
|
|
st.floats(
|
|
min_value=30, max_value=220, allow_nan=False, allow_infinity=False
|
|
),
|
|
),
|
|
}
|
|
),
|
|
max_size=500,
|
|
)
|
|
)
|
|
@settings(max_examples=50)
|
|
def test_mermaid_heartrate_chart_handles_arbitrary_data(self, heartrate_data: list) -> None:
|
|
"""Test Mermaid heartrate chart with arbitrary data."""
|
|
try:
|
|
result = create_mermaid_heartrate_chart(heartrate_data)
|
|
assert isinstance(result, str)
|
|
except Exception as e:
|
|
pytest.fail(f"Unexpected exception: {e}")
|
|
|
|
|
|
class TestAPIResponseFuzzing:
|
|
"""Fuzzing tests for API response handling."""
|
|
|
|
@given(
|
|
st.dictionaries(
|
|
keys=st.text(
|
|
min_size=1, max_size=50, alphabet=st.characters(blacklist_categories=("Cs",))
|
|
),
|
|
values=st.recursive(
|
|
st.one_of(
|
|
st.none(),
|
|
st.booleans(),
|
|
st.integers(min_value=-1e9, max_value=1e9),
|
|
st.floats(allow_nan=False, allow_infinity=False, min_value=-1e9, max_value=1e9),
|
|
st.text(max_size=200, alphabet=st.characters(blacklist_categories=("Cs",))),
|
|
),
|
|
lambda children: st.lists(children, max_size=10)
|
|
| st.dictionaries(
|
|
st.text(
|
|
min_size=1,
|
|
max_size=20,
|
|
alphabet=st.characters(blacklist_categories=("Cs",)),
|
|
),
|
|
children,
|
|
max_size=10,
|
|
),
|
|
max_leaves=50,
|
|
),
|
|
max_size=20,
|
|
)
|
|
)
|
|
@settings(max_examples=50, deadline=None)
|
|
def test_json_formatter_handles_nested_structures(self, data: dict) -> None:
|
|
"""Test JSON formatter with deeply nested structures."""
|
|
try:
|
|
result = format_output(data, "json")
|
|
assert isinstance(result, str)
|
|
# Should be valid JSON
|
|
parsed = json.loads(result)
|
|
assert isinstance(parsed, dict)
|
|
except Exception:
|
|
# Some structures might be too complex, that's OK
|
|
pass
|
|
|
|
@given(
|
|
st.lists(
|
|
st.fixed_dictionaries(
|
|
{
|
|
"day": st.dates(
|
|
min_value=datetime(2020, 1, 1).date(),
|
|
max_value=datetime(2025, 12, 31).date(),
|
|
).map(str),
|
|
"score": st.one_of(st.none(), st.integers(min_value=0, max_value=100)),
|
|
"met": st.fixed_dictionaries(
|
|
{
|
|
"interval": st.floats(
|
|
min_value=1, max_value=300, allow_nan=False, allow_infinity=False
|
|
),
|
|
"items": st.lists(
|
|
st.floats(
|
|
min_value=0, max_value=10, allow_nan=False, allow_infinity=False
|
|
),
|
|
min_size=0,
|
|
max_size=1440,
|
|
),
|
|
}
|
|
),
|
|
}
|
|
),
|
|
max_size=10,
|
|
)
|
|
)
|
|
@settings(max_examples=30, deadline=None)
|
|
def test_formatters_handle_activity_response_structure(self, activity_data: list) -> None:
|
|
"""Test formatters with Oura API-like activity response structure."""
|
|
data = {"activity": activity_data}
|
|
for format_type in ["json", "tree", "markdown"]:
|
|
try:
|
|
result = format_output(data, format_type)
|
|
assert isinstance(result, str)
|
|
# Empty data can produce empty or minimal output, that's OK
|
|
if activity_data:
|
|
assert len(result) > 0
|
|
except Exception as e:
|
|
pytest.fail(f"Failed for {format_type}: {e}")
|
|
|
|
@given(
|
|
st.lists(
|
|
st.fixed_dictionaries(
|
|
{
|
|
"day": st.dates(
|
|
min_value=datetime(2020, 1, 1).date(),
|
|
max_value=datetime(2025, 12, 31).date(),
|
|
).map(str),
|
|
"bpm": st.integers(min_value=30, max_value=220),
|
|
"source": st.sampled_from(["sleep", "rest", "activity"]),
|
|
}
|
|
),
|
|
max_size=50,
|
|
)
|
|
)
|
|
@settings(max_examples=30)
|
|
def test_formatters_handle_heartrate_summary_structure(self, heartrate_data: list) -> None:
|
|
"""Test formatters with heart rate summary structure."""
|
|
for format_type in ["json", "tree"]:
|
|
try:
|
|
result = format_output(heartrate_data, format_type)
|
|
assert isinstance(result, str)
|
|
except Exception as e:
|
|
pytest.fail(f"Failed for {format_type}: {e}")
|
|
|
|
|
|
class TestEdgeCases:
|
|
"""Edge case fuzzing tests."""
|
|
|
|
@given(st.text(alphabet=st.characters(blacklist_categories=("Cs", "Cc")), max_size=1000))
|
|
@settings(max_examples=50)
|
|
def test_formatters_handle_unicode(self, text: str) -> None:
|
|
"""Test that formatters handle Unicode text."""
|
|
data = {"text": text, "value": 123}
|
|
for format_type in ["json", "tree"]:
|
|
try:
|
|
result = format_output(data, format_type)
|
|
assert isinstance(result, str)
|
|
except Exception:
|
|
# Some Unicode might cause issues, that's acceptable
|
|
pass
|
|
|
|
@given(st.lists(st.floats(allow_nan=True, allow_infinity=True), max_size=100))
|
|
@settings(max_examples=30)
|
|
def test_chartjs_handles_special_float_values(self, values: list) -> None:
|
|
"""Test Chart.js config handles NaN and Infinity."""
|
|
# Filter out NaN and Infinity as they're not valid for our use case
|
|
clean_values = [v for v in values if not (float("inf") == abs(v) or v != v)]
|
|
try:
|
|
result = create_chartjs_config(clean_values, "special")
|
|
assert isinstance(result, str)
|
|
except Exception as e:
|
|
pytest.fail(f"Unexpected exception: {e}")
|
|
|
|
@given(st.integers(min_value=0, max_value=10000))
|
|
@settings(max_examples=30)
|
|
def test_chartjs_handles_variable_data_lengths(self, length: int) -> None:
|
|
"""Test Chart.js config with various data lengths."""
|
|
met_items = [1.5] * length
|
|
result = create_chartjs_config(met_items, "var_length")
|
|
assert isinstance(result, str)
|
|
assert "new Chart" in result
|
|
|
|
@given(st.lists(st.dictionaries(st.text(max_size=10), st.none(), max_size=5), max_size=20))
|
|
@settings(max_examples=30)
|
|
def test_formatters_handle_all_none_values(self, data: list) -> None:
|
|
"""Test formatters with all None values."""
|
|
try:
|
|
result = format_output(data, "json")
|
|
assert isinstance(result, str)
|
|
except Exception:
|
|
pass
|
|
|
|
@given(
|
|
st.lists(
|
|
st.fixed_dictionaries(
|
|
{
|
|
"timestamp": st.text(alphabet="XYZ", max_size=20), # Invalid timestamps
|
|
"bpm": st.integers(min_value=30, max_value=220),
|
|
}
|
|
),
|
|
max_size=100,
|
|
)
|
|
)
|
|
@settings(max_examples=30)
|
|
def test_heartrate_chart_handles_malformed_timestamps(self, data: list) -> None:
|
|
"""Test heart rate charts with completely malformed timestamps."""
|
|
result = create_chartjs_heartrate_config(data, "malformed")
|
|
# Should return empty string or valid chart
|
|
assert isinstance(result, str)
|