Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,8 @@ compose.override.yml

# Websocket connection credentials
**/relay_credentials.json

# Mutation testing scratch space and reports
.mutation/
mutants/
reports/
7 changes: 7 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,15 @@ just test-integration
just test-slowest
just bench
just check
just mutation
```

`just mutation` mutation-tests the relay seam and the upload queue.
It writes survivors to `reports/mutation/survivors.txt` and lists each function whose survivor count differs from `tests/mutation-baseline.txt`.
When one does, the recipe fails.
Read a new survivor with `uv run mutmut show <name>` from `.mutation/`, then kill it with a test or record it in the baseline.
Give each equivalent mutant you accept a one-line reason.

## Code Quality

The repository uses:
Expand Down
15 changes: 14 additions & 1 deletion justfile
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,19 @@ test-slowest:
bench:
uv run pytest tests/benchmarks --codspeed

# Mutation-test the relay seam and the upload queue. Survivors land in reports/mutation/.
mutation:
rm -rf .mutation
mkdir -p .mutation/src reports/mutation
cp -r app scripts tests typings pyproject.toml uv.lock .mutation/
cp -r r9cam_models/src/r9cam_models .mutation/src/
cd .mutation && uv run --project .. mutmut run --max-children "$(nproc)"
cd .mutation && uv run --project .. mutmut results | grep -E ': (survived|no tests)$' | sed 's/^ *//; s/: .*//' | LC_ALL=C sort > ../reports/mutation/survivors.txt
@echo "Survivors and untested: $(wc -l < reports/mutation/survivors.txt)"
@echo "Survivor counts per function that differ from tests/mutation-baseline.txt (< baseline, > this run):"
@sed 's/ *#.*//; /^$/d; s/__mutmut_[0-9]*$//' tests/mutation-baseline.txt | uniq -c > .mutation/baseline-counts.txt
@sed 's/__mutmut_[0-9]*$//' reports/mutation/survivors.txt | uniq -c | diff .mutation/baseline-counts.txt -

typecheck:
uv run ty check .

Expand Down Expand Up @@ -55,7 +68,7 @@ build:
uv build

clean:
rm -rf dist/ .venv/ .pytest_cache/ __pycache__/
rm -rf dist/ .venv/ .pytest_cache/ __pycache__/ .mutation/ reports/

# Print the local direct-connection API key from the local key endpoint.
show-key:
Expand Down
12 changes: 12 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
{ include-group = "test" },
{ include-group = "s3" },
"commitizen>=4.8.3",
"mutmut==3.8.0",
"prek>=0.4.11",
"ruff>=0.9.6",
"ty>=0.0.29",
Expand Down Expand Up @@ -90,6 +91,17 @@
version_provider = "pep621"
version_scheme = "semver2"

[tool.mutmut]
# The relay trust boundary and the upload queue. `just mutation` runs this from a
# staged copy where r9cam_models sits under src/, so mutmut derives the same module
# names the tests import.
also_copy = ["scripts", "typings"]
only_mutate = ["app/delivery/*", "app/relay/*", "src/r9cam_models/*"]
# A fixed Hypothesis seed also turns off its example database, so each mutant gets the same verdict every run.
pytest_add_cli_args = ["-q", "-p", "no:cacheprovider", "-p", "no:codspeed", "--hypothesis-seed=0"]
pytest_add_cli_args_test_selection = ["tests/unit", "tests/integration"]
source_paths = ["app", "src/r9cam_models"]

[tool.ruff]
fix = true
line-length = 120
Expand Down
480 changes: 480 additions & 0 deletions tests/mutation-baseline.txt

Large diffs are not rendered by default.

28 changes: 28 additions & 0 deletions tests/unit/core/test_device_seam_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import pytest
from pydantic import TypeAdapter, ValidationError
from r9cam_models.device_seam import _normalize_relay_path

from r9cam_models import (
PAIRING_CODE_ALPHABET,
Expand Down Expand Up @@ -192,6 +193,33 @@ def test_relay_command_policy_rejects_out_of_scope_commands(method: str, path: s
assert not relay_command_is_allowed(method, path)


@pytest.mark.parametrize(
("path", "expected"),
[
("/", "/"),
("//", "/"),
("/camera/", "/camera"),
("/camera ", "/camera "),
("/cameraX", "/cameraX"),
("/preview/hls/X", "/preview/hls/X"),
],
)
def test_relay_path_normalization_only_strips_trailing_slashes(path: str, expected: str) -> None:
"""Only trailing slashes are dropped, and the root path stays the root path."""
assert _normalize_relay_path(path) == expected


def test_relay_command_policy_accepts_trailing_slash() -> None:
"""A trailing slash should reach the same allowlist entry as the bare path."""
assert relay_command_is_allowed("GET", "/camera/")


@pytest.mark.parametrize("path", ["/camera ", "/camera\t", "/cameraX", "/camera/X"])
def test_relay_command_policy_rejects_near_miss_suffixes(path: str) -> None:
"""Trailing whitespace or letters must not normalize into an allowlisted path."""
assert not relay_command_is_allowed("GET", path)


@pytest.mark.parametrize(
"content_type",
[
Expand Down
Loading
Loading