merge: testing → main (reconcile 2-week divergence)
This commit is contained in:
0
tests/api/realism/__init__.py
Normal file
0
tests/api/realism/__init__.py
Normal file
109
tests/api/realism/test_config_api.py
Normal file
109
tests/api/realism/test_config_api.py
Normal 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
|
||||
170
tests/api/realism/test_personas_api.py
Normal file
170
tests/api/realism/test_personas_api.py
Normal 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",
|
||||
}
|
||||
146
tests/api/realism/test_synthetic_files_api.py
Normal file
146
tests/api/realism/test_synthetic_files_api.py
Normal 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
|
||||
Reference in New Issue
Block a user