merge: testing → main (reconcile 2-week divergence)

This commit is contained in:
2026-04-28 18:36:00 -04:00
parent 499836c9e4
commit 862e4dbb31
1235 changed files with 160255 additions and 7996 deletions

View File

View File

@@ -0,0 +1,109 @@
"""GET/PUT /api/v1/realism/config — operator-tunable weights."""
from __future__ import annotations
import json
from unittest.mock import AsyncMock, patch
import pytest
from fastapi import HTTPException
from decnet.realism import planner
@pytest.fixture(autouse=True)
def _reset_planner():
yield
planner.reset_to_defaults()
@pytest.mark.asyncio
async def test_get_returns_defaults_when_no_row():
from decnet.web.router.realism.api_config import get_config
with patch("decnet.web.router.realism.api_config.repo") as mock_repo:
mock_repo.get_realism_config = AsyncMock(return_value=None)
result = await get_config(user={"uuid": "u", "role": "viewer"})
assert result["canary_probability"] == pytest.approx(0.03)
assert result["user_class_weights"]
@pytest.mark.asyncio
async def test_get_hydrates_from_db_row():
from decnet.web.router.realism.api_config import get_config
stored = json.dumps({"canary_probability": 0.10})
with patch("decnet.web.router.realism.api_config.repo") as mock_repo:
mock_repo.get_realism_config = AsyncMock(
return_value={"key": "weights", "value": stored},
)
result = await get_config(user={"uuid": "u", "role": "viewer"})
assert result["canary_probability"] == pytest.approx(0.10)
@pytest.mark.asyncio
async def test_get_serves_defaults_when_stored_payload_invalid():
"""Stored JSON parsed but failed planner validation: log + serve
defaults rather than 500."""
from decnet.web.router.realism.api_config import get_config
stored = json.dumps({"canary_probability": 9.0})
with patch("decnet.web.router.realism.api_config.repo") as mock_repo:
mock_repo.get_realism_config = AsyncMock(
return_value={"key": "weights", "value": stored},
)
result = await get_config(user={"uuid": "u", "role": "viewer"})
assert result["canary_probability"] == pytest.approx(0.03)
@pytest.mark.asyncio
async def test_put_persists_and_returns_snapshot():
from decnet.web.router.realism.api_config import put_config
with patch("decnet.web.router.realism.api_config.repo") as mock_repo:
mock_repo.set_realism_config = AsyncMock()
result = await put_config(
body={"canary_probability": 0.20},
user={"uuid": "u", "role": "admin", "username": "anti"},
)
assert result["canary_probability"] == pytest.approx(0.20)
mock_repo.set_realism_config.assert_awaited_once()
args, _ = mock_repo.set_realism_config.call_args
assert args[0] == "weights"
persisted = json.loads(args[1])
assert persisted["canary_probability"] == pytest.approx(0.20)
@pytest.mark.asyncio
async def test_put_returns_400_on_invalid_payload():
from decnet.web.router.realism.api_config import put_config
with patch("decnet.web.router.realism.api_config.repo") as mock_repo:
mock_repo.set_realism_config = AsyncMock()
with pytest.raises(HTTPException) as exc:
await put_config(
body={"canary_probability": 9.0},
user={"uuid": "u", "role": "admin", "username": "anti"},
)
assert exc.value.status_code == 400
# No DB write on validation failure.
mock_repo.set_realism_config.assert_not_called()
@pytest.mark.asyncio
async def test_put_rejects_non_dict_body():
from decnet.web.router.realism.api_config import put_config
with pytest.raises(HTTPException) as exc:
await put_config(
body=[1, 2, 3], # type: ignore[arg-type]
user={"uuid": "u", "role": "admin", "username": "anti"},
)
assert exc.value.status_code == 400

View File

@@ -0,0 +1,170 @@
"""GET/PUT /api/v1/realism/personas — global persona pool CRUD."""
from __future__ import annotations
import json
import pytest
from decnet.realism import personas_pool as global_pool
from decnet.web.router.realism.api_personas import (
list_personas,
replace_personas,
)
@pytest.fixture(autouse=True)
def _reset_pool():
global_pool.reset_cache()
yield
global_pool.reset_cache()
_VALID = [
{
"name": "John Smith",
"email": "john@corp.com",
"role": "COO",
"tone": "formal",
"mannerisms": ["uses 'Best regards'"],
},
{
"name": "Sarah Johnson",
"email": "sarah@corp.com",
"role": "PM",
"tone": "direct",
"mannerisms": ["uses bullets"],
},
]
@pytest.mark.asyncio
async def test_list_returns_empty_when_no_pool(tmp_path, monkeypatch):
monkeypatch.setenv(
"DECNET_REALISM_PERSONAS", str(tmp_path / "missing.json"),
)
result = await list_personas(user={"uuid": "u", "role": "viewer"})
assert result["personas"] == []
assert result["path"].endswith("missing.json")
@pytest.mark.asyncio
async def test_list_returns_existing_pool(tmp_path, monkeypatch):
pool = tmp_path / "pool.json"
pool.write_text(json.dumps(_VALID))
monkeypatch.setenv("DECNET_REALISM_PERSONAS", str(pool))
result = await list_personas(user={"uuid": "u", "role": "viewer"})
assert len(result["personas"]) == 2
assert {p["email"] for p in result["personas"]} == {
"john@corp.com", "sarah@corp.com",
}
@pytest.mark.asyncio
async def test_replace_writes_canonical_file(tmp_path, monkeypatch):
dest = tmp_path / "pool.json"
monkeypatch.setenv("DECNET_REALISM_PERSONAS", str(dest))
result = await replace_personas(
body={"personas": _VALID},
user={"uuid": "u", "role": "admin", "username": "anti"},
)
assert len(result["personas"]) == 2
assert dest.exists()
written = json.loads(dest.read_text())
assert {p["email"] for p in written} == {
"john@corp.com", "sarah@corp.com",
}
@pytest.mark.asyncio
async def test_replace_with_empty_list_clears_pool(tmp_path, monkeypatch):
"""Operator deliberately wiping the pool is allowed — empty list is
valid and means "no fleet personas, skip fleet mail deckies"."""
dest = tmp_path / "pool.json"
dest.write_text(json.dumps(_VALID))
monkeypatch.setenv("DECNET_REALISM_PERSONAS", str(dest))
result = await replace_personas(
body={"personas": []},
user={"uuid": "u", "role": "admin", "username": "anti"},
)
assert result["personas"] == []
assert json.loads(dest.read_text()) == []
@pytest.mark.asyncio
async def test_replace_rejects_non_list_payload(tmp_path, monkeypatch):
from fastapi import HTTPException
monkeypatch.setenv(
"DECNET_REALISM_PERSONAS", str(tmp_path / "pool.json"),
)
with pytest.raises(HTTPException) as exc:
await replace_personas(
body={"personas": "not-a-list"},
user={"uuid": "u", "role": "admin", "username": "anti"},
)
assert exc.value.status_code == 400
@pytest.mark.asyncio
async def test_replace_rejects_all_invalid_payload(tmp_path, monkeypatch):
"""Sending a non-empty list where *every* entry is invalid is almost
certainly an operator schema mistake — fail loudly rather than
silently writing an empty pool."""
from fastapi import HTTPException
monkeypatch.setenv(
"DECNET_REALISM_PERSONAS", str(tmp_path / "pool.json"),
)
with pytest.raises(HTTPException) as exc:
await replace_personas(
body={"personas": [{"name": "broken", "email": "no-at-symbol"}]},
user={"uuid": "u", "role": "admin", "username": "anti"},
)
assert exc.value.status_code == 400
assert "validation" in exc.value.detail.lower()
@pytest.mark.asyncio
async def test_replace_drops_partially_invalid_entries(tmp_path, monkeypatch):
"""One bad apple doesn't kill the request — invalid entries get
dropped, valid ones land, response shows what stuck."""
dest = tmp_path / "pool.json"
monkeypatch.setenv("DECNET_REALISM_PERSONAS", str(dest))
result = await replace_personas(
body={"personas": [
_VALID[0],
{"name": "broken", "email": "no-at-symbol"},
_VALID[1],
]},
user={"uuid": "u", "role": "admin", "username": "anti"},
)
assert len(result["personas"]) == 2
assert {p["email"] for p in result["personas"]} == {
"john@corp.com", "sarah@corp.com",
}
@pytest.mark.asyncio
async def test_get_then_put_round_trips_through_pool(tmp_path, monkeypatch):
"""The worker reads the same file the API writes — verify the
write-then-read cycle leaves the pool in the expected state."""
dest = tmp_path / "pool.json"
monkeypatch.setenv("DECNET_REALISM_PERSONAS", str(dest))
await replace_personas(
body={"personas": _VALID},
user={"uuid": "u", "role": "admin", "username": "anti"},
)
listed = await list_personas(user={"uuid": "u", "role": "viewer"})
assert {p["email"] for p in listed["personas"]} == {
"john@corp.com", "sarah@corp.com",
}
# And the worker's loader sees the same data.
loaded = global_pool.load()
assert {p.email for p in loaded} == {
"john@corp.com", "sarah@corp.com",
}

View File

@@ -0,0 +1,146 @@
"""GET /api/v1/realism/synthetic-files — paginated browser API."""
from __future__ import annotations
from unittest.mock import AsyncMock, patch
import pytest
from fastapi import HTTPException
from decnet.web.db.models.realism import SYNTHETIC_FILE_BODY_LIMIT
def _row(**over):
base = {
"uuid": "sf-1",
"decky_uuid": "d-1",
"path": "/home/admin/notes.txt",
"persona": "admin",
"content_class": "note",
"created_at": "2026-04-27T10:00:00+00:00",
"last_modified": "2026-04-27T10:00:00+00:00",
"edit_count": 0,
"content_hash": "deadbeef" * 8,
"last_body": "hello world",
}
base.update(over)
return base
@pytest.mark.asyncio
async def test_list_returns_paginated_envelope():
from decnet.web.router.realism.api_synthetic_files import (
list_synthetic_files,
)
rows = [_row(uuid=f"sf-{i}") for i in range(3)]
with patch(
"decnet.web.router.realism.api_synthetic_files.repo"
) as mock_repo:
mock_repo.list_synthetic_files = AsyncMock(return_value=rows)
mock_repo.count_synthetic_files = AsyncMock(return_value=3)
result = await list_synthetic_files(
limit=50, offset=0,
decky_uuid=None, persona=None, content_class=None,
user={"uuid": "u", "role": "viewer"},
)
assert result["total"] == 3
assert result["limit"] == 50
assert result["offset"] == 0
assert len(result["data"]) == 3
# List view drops the body to keep the payload small.
for r in result["data"]:
assert "last_body" not in r
@pytest.mark.asyncio
async def test_list_forwards_filters_to_repo():
from decnet.web.router.realism.api_synthetic_files import (
list_synthetic_files,
)
with patch(
"decnet.web.router.realism.api_synthetic_files.repo"
) as mock_repo:
mock_repo.list_synthetic_files = AsyncMock(return_value=[])
mock_repo.count_synthetic_files = AsyncMock(return_value=0)
await list_synthetic_files(
limit=10, offset=20,
decky_uuid="d-7", persona="alice", content_class="todo",
user={"uuid": "u", "role": "viewer"},
)
mock_repo.list_synthetic_files.assert_awaited_once_with(
decky_uuid="d-7", persona="alice", content_class="todo",
limit=10, offset=20,
)
mock_repo.count_synthetic_files.assert_awaited_once_with(
decky_uuid="d-7", persona="alice", content_class="todo",
)
@pytest.mark.asyncio
async def test_get_detail_returns_body_with_truncated_false():
from decnet.web.router.realism.api_synthetic_files import (
get_synthetic_file,
)
with patch(
"decnet.web.router.realism.api_synthetic_files.repo"
) as mock_repo:
mock_repo.get_synthetic_file = AsyncMock(return_value=_row(
last_body="short body",
))
result = await get_synthetic_file(
uuid="sf-1",
user={"uuid": "u", "role": "viewer"},
)
assert result["last_body"] == "short body"
assert result["truncated"] is False
@pytest.mark.asyncio
async def test_get_detail_marks_truncated_when_at_cap():
from decnet.web.router.realism.api_synthetic_files import (
get_synthetic_file,
)
body = "X" * SYNTHETIC_FILE_BODY_LIMIT
with patch(
"decnet.web.router.realism.api_synthetic_files.repo"
) as mock_repo:
mock_repo.get_synthetic_file = AsyncMock(return_value=_row(
last_body=body,
))
result = await get_synthetic_file(
uuid="sf-1",
user={"uuid": "u", "role": "viewer"},
)
assert len(result["last_body"]) == SYNTHETIC_FILE_BODY_LIMIT
assert result["truncated"] is True
@pytest.mark.asyncio
async def test_get_detail_404_when_missing():
from decnet.web.router.realism.api_synthetic_files import (
get_synthetic_file,
)
with patch(
"decnet.web.router.realism.api_synthetic_files.repo"
) as mock_repo:
mock_repo.get_synthetic_file = AsyncMock(return_value=None)
with pytest.raises(HTTPException) as exc:
await get_synthetic_file(
uuid="missing",
user={"uuid": "u", "role": "viewer"},
)
assert exc.value.status_code == 404