# Story 7.6: Add Property-Based Serialization Tests

**Status:** Complete
**Priority:** P1
**Depends on:** None
**Effort:** L (2-3 days)

---

## Problem Statement

The current serialization tests use hand-crafted examples that test known edge cases. This approach:

- **Misses unknown edge cases**: Only tests what developers thought to include
- **Limited coverage of input space**: A few examples can't cover all valid inputs
- **Brittle to changes**: Adding new data types requires new manual tests

Property-based testing (using Hypothesis) would:
- Generate thousands of random inputs automatically
- Find edge cases developers didn't anticipate
- Prove invariants hold across the entire input space
- Shrink failing cases to minimal reproducible examples

**Target areas for property-based tests:**
1. **JSON serialization**: Any valid Python dict should serialize without crashing
2. **Field redaction**: Redacted output should never contain original sensitive values
3. **Event enrichment**: Enriched events should contain all original fields
4. **Log level filtering**: Filtered events should respect level hierarchy

---

## Goals

1. **Add Hypothesis dependency** for property-based testing
2. **Create property tests for serialization** invariants
3. **Create property tests for redaction** correctness
4. **Establish patterns** for future property-based tests

---

## Acceptance Criteria

- [ ] Hypothesis added to dev dependencies
- [ ] Property tests for JSON serialization (round-trip, no exceptions)
- [ ] Property tests for field redaction (sensitive values never leak)
- [ ] Property tests for event structure (required fields preserved)
- [ ] Tests integrated into CI (with reasonable time limits)
- [ ] At least 3 modules covered by property-based tests
- [ ] Documentation for writing new property tests

---

## Technical Approach

### 1. Add Hypothesis Dependency

**pyproject.toml:**
```toml
[project.optional-dependencies]
dev = [
    # ... existing deps ...
    "hypothesis>=6.100.0",
]
```

### 2. Serialization Property Tests

**File: `tests/property/test_serialization_properties.py`**

```python
"""
Property-based tests for JSON serialization.

These tests verify invariants that must hold for ALL valid inputs,
not just hand-crafted examples.
"""

from __future__ import annotations

import json
from typing import Any

import pytest
from hypothesis import given, settings, assume
from hypothesis import strategies as st

from fapilog.core.serialization import serialize_event, safe_serialize


# Strategy for JSON-serializable Python values
json_primitives = st.one_of(
    st.none(),
    st.booleans(),
    st.integers(),
    st.floats(allow_nan=False, allow_infinity=False),
    st.text(),
)

# Recursive strategy for nested structures
json_values = st.recursive(
    json_primitives,
    lambda children: st.one_of(
        st.lists(children, max_size=10),
        st.dictionaries(st.text(), children, max_size=10),
    ),
    max_leaves=50,
)

# Strategy for log event dicts
log_events = st.fixed_dictionaries(
    {
        "timestamp": st.datetimes().map(lambda dt: dt.isoformat()),
        "level": st.sampled_from(["DEBUG", "INFO", "WARNING", "ERROR"]),
        "message": st.text(min_size=1, max_size=200),
    },
    optional={
        "extra": st.dictionaries(st.text(), json_values, max_size=5),
        "exception": st.none() | st.text(),
        "context": st.dictionaries(st.text(), json_values, max_size=3),
    },
)


class TestSerializationProperties:
    """Property-based tests for serialization."""

    @given(event=log_events)
    @settings(max_examples=500)
    def test_serialize_never_crashes(self, event: dict[str, Any]) -> None:
        """Property: serialize_event should never raise for valid events."""
        result = serialize_event(event)
        assert isinstance(result, (str, bytes))

    @given(event=log_events)
    @settings(max_examples=500)
    def test_serialized_output_is_valid_json(self, event: dict[str, Any]) -> None:
        """Property: serialized output should always be valid JSON."""
        result = serialize_event(event)

        # Should parse without error
        if isinstance(result, bytes):
            parsed = json.loads(result.decode("utf-8"))
        else:
            parsed = json.loads(result)

        assert isinstance(parsed, dict)

    @given(event=log_events)
    @settings(max_examples=500)
    def test_required_fields_preserved(self, event: dict[str, Any]) -> None:
        """Property: required fields should survive serialization."""
        result = serialize_event(event)

        if isinstance(result, bytes):
            parsed = json.loads(result.decode("utf-8"))
        else:
            parsed = json.loads(result)

        # Required fields must be present
        assert "timestamp" in parsed
        assert "level" in parsed
        assert "message" in parsed

    @given(data=st.dictionaries(st.text(), json_values, max_size=20))
    @settings(max_examples=200)
    def test_safe_serialize_handles_any_dict(self, data: dict[str, Any]) -> None:
        """Property: safe_serialize should handle any dict without crashing."""
        # safe_serialize should either succeed or return a fallback
        result = safe_serialize(data)
        assert result is not None


class TestSerializationEdgeCases:
    """Property tests that specifically target edge cases."""

    @given(
        event=log_events,
        depth=st.integers(min_value=1, max_value=50),
    )
    @settings(max_examples=100)
    def test_deeply_nested_structures(
        self, event: dict[str, Any], depth: int
    ) -> None:
        """Property: deep nesting should not cause stack overflow."""
        # Build a deeply nested structure
        nested: dict[str, Any] = {"value": "leaf"}
        for _ in range(depth):
            nested = {"nested": nested}

        event["deep"] = nested

        # Should not crash
        result = serialize_event(event)
        assert result is not None

    @given(
        event=log_events,
        size=st.integers(min_value=100, max_value=1000),
    )
    @settings(max_examples=50)
    def test_large_string_values(
        self, event: dict[str, Any], size: int
    ) -> None:
        """Property: large strings should serialize correctly."""
        event["large_field"] = "x" * size

        result = serialize_event(event)

        if isinstance(result, bytes):
            parsed = json.loads(result.decode("utf-8"))
        else:
            parsed = json.loads(result)

        assert len(parsed["large_field"]) == size
```

### 3. Redaction Property Tests

**File: `tests/property/test_redaction_properties.py`**

```python
"""
Property-based tests for field redaction.

Critical security property: sensitive values must NEVER appear in output.
"""

from __future__ import annotations

import json
import re
from typing import Any

import pytest
from hypothesis import given, settings, assume
from hypothesis import strategies as st

from fapilog.plugins.redactors.field_mask import FieldMaskRedactor


# Strategy for sensitive field names
sensitive_field_names = st.sampled_from([
    "password", "secret", "api_key", "token", "credential",
    "ssn", "credit_card", "private_key", "auth",
])

# Strategy for sensitive values (things that should be redacted)
sensitive_values = st.text(min_size=1, max_size=100).filter(
    lambda s: s.strip() and s != "***"
)

# Strategy for events with sensitive data
events_with_secrets = st.fixed_dictionaries(
    {
        "message": st.text(min_size=1),
        "timestamp": st.datetimes().map(lambda dt: dt.isoformat()),
    },
).flatmap(
    lambda base: st.fixed_dictionaries(
        {**{k: st.just(v) for k, v in base.items()}},
        optional={
            "password": sensitive_values,
            "secret": sensitive_values,
            "api_key": sensitive_values,
            "token": sensitive_values,
        },
    )
)


class TestRedactionProperties:
    """Property-based tests for redaction correctness."""

    @given(
        secret_value=sensitive_values,
        field_name=sensitive_field_names,
    )
    @settings(max_examples=500)
    def test_sensitive_value_never_in_output(
        self, secret_value: str, field_name: str
    ) -> None:
        """Property: original sensitive value must NEVER appear in redacted output."""
        redactor = FieldMaskRedactor(
            fields_to_mask=[field_name],
            mask_nested=True,
        )

        event = {
            "message": "test",
            "timestamp": "2026-01-01T00:00:00",
            field_name: secret_value,
        }

        redacted = redactor.redact(event)

        # Convert to string for searching
        output_str = json.dumps(redacted)

        # The original secret value must not appear
        assert secret_value not in output_str, (
            f"Secret value '{secret_value}' found in redacted output"
        )

    @given(event=events_with_secrets)
    @settings(max_examples=500)
    def test_non_sensitive_fields_preserved(self, event: dict[str, Any]) -> None:
        """Property: non-sensitive fields should not be modified."""
        redactor = FieldMaskRedactor(
            fields_to_mask=["password", "secret", "api_key", "token"],
            mask_nested=True,
        )

        redacted = redactor.redact(event.copy())

        # Non-sensitive fields should be unchanged
        assert redacted["message"] == event["message"]
        assert redacted["timestamp"] == event["timestamp"]

    @given(
        secret_value=sensitive_values,
        nesting_path=st.lists(st.text(min_size=1, max_size=10), min_size=1, max_size=5),
    )
    @settings(max_examples=200)
    def test_nested_secrets_redacted(
        self, secret_value: str, nesting_path: list[str]
    ) -> None:
        """Property: secrets at any nesting depth should be redacted."""
        # Avoid path components that might conflict with sensitive field names
        assume(not any(p in ["password", "secret"] for p in nesting_path))

        redactor = FieldMaskRedactor(
            fields_to_mask=["password"],
            mask_nested=True,
        )

        # Build nested structure
        event: dict[str, Any] = {"message": "test"}
        current = event
        for key in nesting_path[:-1]:
            current[key] = {}
            current = current[key]
        current[nesting_path[-1]] = {"password": secret_value}

        redacted = redactor.redact(event)

        # Secret should not appear anywhere in output
        output_str = json.dumps(redacted)
        assert secret_value not in output_str
```

### 4. CI Integration

**pytest.ini or pyproject.toml:**
```toml
[tool.pytest.ini_options]
markers = [
    "property: property-based tests (may be slow)",
]

# Limit hypothesis examples in CI for speed
filterwarnings = [
    "ignore::hypothesis.errors.NonInteractiveExampleWarning",
]
```

**tox.ini adjustment:**
```ini
[testenv]
setenv =
    # Limit hypothesis examples in CI
    HYPOTHESIS_MAX_EXAMPLES=100
```

---

## Files Changed

| File | Action |
|------|--------|
| `pyproject.toml` | Add hypothesis dependency |
| `tests/property/__init__.py` | New directory |
| `tests/property/test_serialization_properties.py` | New file |
| `tests/property/test_redaction_properties.py` | New file |
| `tests/property/test_enrichment_properties.py` | New file |
| `tox.ini` | Add HYPOTHESIS_MAX_EXAMPLES |

**Lines added:** ~400

---

## Risk Assessment

**Risk Level: Medium**

- **New dependency**: Hypothesis is well-maintained but adds to dependency tree
- **Test time**: Property tests can be slow; need careful settings
- **Flakiness risk**: Random generation could cause intermittent failures
- **Learning curve**: Team needs to learn Hypothesis patterns

**Mitigations:**
- Set reasonable `max_examples` limits
- Use `@settings(deadline=None)` for slow tests
- Document patterns for the team
- Pin hypothesis version

---

## Rollback Plan

1. Remove hypothesis from `pyproject.toml`
2. Delete `tests/property/` directory
3. Remove HYPOTHESIS_MAX_EXAMPLES from `tox.ini`

---

## Definition of Done

- [ ] Hypothesis added to dev dependencies
- [ ] `tests/property/` directory created with `__init__.py`
- [ ] Serialization property tests pass
- [ ] Redaction property tests pass
- [ ] At least one more module with property tests
- [ ] CI runs property tests with time limit
- [ ] Documentation added for writing property tests
- [ ] All tests pass
- [ ] Coverage >= 90%

---

## Related Stories

- **7.3**: Redaction Integration Tests (complements with property verification)
- **7.8**: Mutation Testing (property tests help mutation testing)

---

## Future Considerations

- Add property tests for more modules (filters, processors)
- Consider stateful testing with Hypothesis for worker lifecycle
- Evaluate property tests for API contract verification
- Add property tests to plugin author guidelines
