Update
This commit is contained in:
@@ -1,334 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from datetime import timedelta
|
||||
import pytest
|
||||
import requests
|
||||
from tests.conftest import BASE_URL, run_async
|
||||
from devplacepy.database import db, get_table, init_db, refresh_snapshot
|
||||
from devplacepy.utils import generate_uid
|
||||
from devplacepy import config, project_files
|
||||
from devplacepy.services.containers import api, store, runtime
|
||||
from devplacepy.services.containers.backend.base import Mount, PortMapping, RunSpec
|
||||
from devplacepy.services.containers.backend.docker_cli import build_run_argv, parse_size
|
||||
from devplacepy.services.containers.backend.fake import FakeBackend
|
||||
from devplacepy.services.containers.service import ContainerService
|
||||
from devplacepy.services.containers.api import INSTANCE_LABEL
|
||||
from devplacepy.services.devii.tasks.schedule import Schedule, now_utc
|
||||
_CONTAINER_TABLES = (
|
||||
"instances",
|
||||
"instance_events",
|
||||
"instance_metrics",
|
||||
"instance_schedules",
|
||||
)
|
||||
@pytest.fixture(autouse=True)
|
||||
def _init_db_containers():
|
||||
init_db()
|
||||
yield
|
||||
@pytest.fixture
|
||||
def env(tmp_path, monkeypatch):
|
||||
fake = FakeBackend()
|
||||
runtime.set_backend(fake)
|
||||
monkeypatch.setattr("devplacepy.config.CONTAINER_WORKSPACES_DIR", tmp_path / "ws")
|
||||
pid = "ctest-p1"
|
||||
project = {"uid": pid, "slug": "ctest", "title": "C", "user_uid": "ctest-u1"}
|
||||
user = {"uid": "ctest-u1", "username": "ctestadmin"}
|
||||
get_table("users").insert(
|
||||
{
|
||||
"uid": user["uid"],
|
||||
"username": user["username"],
|
||||
"email": "ctestadmin@example.com",
|
||||
"api_key": generate_uid(),
|
||||
"password_hash": "",
|
||||
"role": "Member",
|
||||
"is_active": True,
|
||||
"level": 1,
|
||||
"xp": 0,
|
||||
"stars": 0,
|
||||
"deleted_at": None,
|
||||
"deleted_by": None,
|
||||
}
|
||||
)
|
||||
refresh_snapshot()
|
||||
project_files.write_text_file(pid, user, "app.py", "print(1)\n")
|
||||
yield {"fake": fake, "project": project, "user": user}
|
||||
runtime.set_backend(None)
|
||||
get_table("users").delete(uid=user["uid"])
|
||||
for table in _CONTAINER_TABLES:
|
||||
if table in db.tables:
|
||||
for row in [r for r in get_table(table).find()]:
|
||||
if str(row.get("project_uid", "")).startswith("ctest"):
|
||||
get_table(table).delete(uid=row["uid"])
|
||||
for row in list(get_table("project_files").find()):
|
||||
if str(row.get("project_uid", "")).startswith("ctest"):
|
||||
get_table("project_files").delete(uid=row["uid"])
|
||||
def _ready_instance(env, **kwargs):
|
||||
return run_async(
|
||||
api.create_instance(env["project"], name=kwargs.pop("name", "inst"), **kwargs)
|
||||
)
|
||||
def _promote_admin(username: str) -> None:
|
||||
users = get_table("users")
|
||||
user = users.find_one(username=username)
|
||||
if user:
|
||||
users.update({"uid": user["uid"], "role": "Admin"}, ["uid"])
|
||||
def _api_key(username: str) -> str:
|
||||
refresh_snapshot()
|
||||
return get_table("users").find_one(username=username)["api_key"]
|
||||
|
||||
|
||||
def test_build_run_argv_exact():
|
||||
spec = RunSpec(
|
||||
image="ppy:latest",
|
||||
name="inst",
|
||||
labels={INSTANCE_LABEL: "u1"},
|
||||
env={"A": "B"},
|
||||
cpu_limit="1.5",
|
||||
mem_limit="512m",
|
||||
ports=[PortMapping(8080, 80)],
|
||||
mounts=[Mount("/ws", "/app")],
|
||||
restart_policy="on-failure",
|
||||
command=["python", "app.py"],
|
||||
)
|
||||
argv = build_run_argv(spec)
|
||||
assert argv[:5] == ["docker", "run", "-d", "--name", "inst"]
|
||||
assert "--label" in argv and f"{INSTANCE_LABEL}=u1" in argv
|
||||
assert "--cpus" in argv and "1.5" in argv
|
||||
assert "-p" in argv and "8080:80/tcp" in argv
|
||||
assert "-v" in argv and "/ws:/app:rw" in argv
|
||||
assert "--restart" in argv and "on-failure" in argv
|
||||
assert argv[-3:] == ["ppy:latest", "python", "app.py"]
|
||||
|
||||
|
||||
def test_never_policy_not_passed_to_docker():
|
||||
spec = RunSpec(image="ppy:latest", name="n", restart_policy="never")
|
||||
assert "--restart" not in build_run_argv(spec)
|
||||
|
||||
|
||||
def test_parse_size():
|
||||
assert parse_size("1.0GiB") == 1024**3
|
||||
assert parse_size("512MB") == 512 * 1024**2
|
||||
|
||||
|
||||
def test_create_instance_uses_shared_image(env):
|
||||
inst = run_async(
|
||||
api.create_instance(
|
||||
env["project"], name="inst", actor=("user", env["user"]["uid"])
|
||||
)
|
||||
)
|
||||
assert inst["name"] == "inst"
|
||||
assert inst["owner_uid"] == "ctest-u1"
|
||||
spec = api.run_spec_for(inst, config.CONTAINER_IMAGE)
|
||||
assert spec.image == config.CONTAINER_IMAGE
|
||||
assert any(m.container == "/app" for m in spec.mounts)
|
||||
|
||||
|
||||
def test_create_instance_requires_built_image(env):
|
||||
async def no_image(ref):
|
||||
return False
|
||||
|
||||
env["fake"].image_exists = no_image
|
||||
with pytest.raises(api.ContainerError):
|
||||
run_async(api.create_instance(env["project"], name="inst"))
|
||||
|
||||
|
||||
def test_reconcile_launches_and_stops(env):
|
||||
inst = _ready_instance(env, restart_policy="never", autostart=True)
|
||||
assert inst["desired_state"] == store.DESIRED_RUNNING
|
||||
service = ContainerService()
|
||||
run_async(service.run_once())
|
||||
refresh_snapshot()
|
||||
inst = store.get_instance(inst["uid"])
|
||||
assert inst["status"] == store.ST_RUNNING and inst["container_id"]
|
||||
assert [r.name for r in run_async(env["fake"].ps())] == [inst["slug"]]
|
||||
api.set_desired_state(inst, store.DESIRED_STOPPED)
|
||||
run_async(service.run_once())
|
||||
refresh_snapshot()
|
||||
assert store.get_instance(inst["uid"])["status"] == store.ST_STOPPED
|
||||
|
||||
|
||||
def test_manual_start_relaunches_after_never_policy_exit(env):
|
||||
fake = env["fake"]
|
||||
inst = _ready_instance(env, restart_policy="never", autostart=True)
|
||||
service = ContainerService()
|
||||
run_async(service.run_once())
|
||||
refresh_snapshot()
|
||||
inst = store.get_instance(inst["uid"])
|
||||
cid = inst["container_id"]
|
||||
assert inst["status"] == store.ST_RUNNING and cid
|
||||
|
||||
fake._set_state(cid, "exited", 0)
|
||||
run_async(service.run_once())
|
||||
refresh_snapshot()
|
||||
inst = store.get_instance(inst["uid"])
|
||||
assert inst["status"] == store.ST_STOPPED
|
||||
assert inst["desired_state"] == store.DESIRED_STOPPED
|
||||
|
||||
api.set_desired_state(inst, store.DESIRED_RUNNING)
|
||||
refresh_snapshot()
|
||||
run_async(service.run_once())
|
||||
refresh_snapshot()
|
||||
inst = store.get_instance(inst["uid"])
|
||||
assert inst["status"] == store.ST_RUNNING
|
||||
assert inst["desired_state"] == store.DESIRED_RUNNING
|
||||
assert cid in fake.removed
|
||||
assert inst["container_id"] and inst["container_id"] != cid
|
||||
|
||||
|
||||
def test_reconcile_reaps_orphan(env):
|
||||
fake = env["fake"]
|
||||
run_async(
|
||||
fake.run(
|
||||
RunSpec(
|
||||
image="ppy:latest", name="ghost", labels={INSTANCE_LABEL: "missing-uid"}
|
||||
)
|
||||
)
|
||||
)
|
||||
service = ContainerService()
|
||||
run_async(service.run_once())
|
||||
assert not run_async(fake.ps())
|
||||
assert fake.removed
|
||||
|
||||
|
||||
def test_reconcile_removes_marked_instance(env):
|
||||
inst = _ready_instance(env, autostart=True)
|
||||
service = ContainerService()
|
||||
run_async(service.run_once())
|
||||
refresh_snapshot()
|
||||
inst = store.get_instance(inst["uid"])
|
||||
api.mark_for_removal(inst)
|
||||
run_async(service.run_once())
|
||||
refresh_snapshot()
|
||||
assert store.get_instance(inst["uid"]) is None
|
||||
assert not run_async(env["fake"].ps())
|
||||
|
||||
|
||||
def test_schedule_fires(env):
|
||||
inst = _ready_instance(env, autostart=False)
|
||||
assert inst["desired_state"] == store.DESIRED_STOPPED
|
||||
past = Schedule(kind="once", run_at=now_utc() - timedelta(hours=1))
|
||||
api.add_schedule(inst, "start", past)
|
||||
service = ContainerService()
|
||||
run_async(service._fire_schedules())
|
||||
refresh_snapshot()
|
||||
assert store.get_instance(inst["uid"])["desired_state"] == store.DESIRED_RUNNING
|
||||
|
||||
|
||||
def test_ingress_validation(env):
|
||||
from devplacepy.services.containers.backend.base import PortMapping
|
||||
|
||||
assert api.validate_ingress("zwoeks", 8899, [PortMapping(8899, 8899)]) == (
|
||||
"zwoeks",
|
||||
8899,
|
||||
)
|
||||
assert api.validate_ingress("", None, []) == ("", 0)
|
||||
with pytest.raises(api.ContainerError):
|
||||
api.validate_ingress("BAD SLUG", None, [PortMapping(80, 80)])
|
||||
with pytest.raises(api.ContainerError):
|
||||
api.validate_ingress("x", 9999, [PortMapping(80, 80)])
|
||||
store.create_instance(
|
||||
{
|
||||
"uid": "z",
|
||||
"project_uid": "ctest-p1",
|
||||
"name": "z",
|
||||
"ports_json": "[]",
|
||||
"ingress_slug": "taken",
|
||||
}
|
||||
)
|
||||
with pytest.raises(api.ContainerError):
|
||||
api.validate_ingress("taken", None, [PortMapping(80, 80)])
|
||||
|
||||
|
||||
def test_validate_boot_languages():
|
||||
assert api.validate_boot("none", "ignored") == ("none", "")
|
||||
assert api.validate_boot("python", "print(1)") == ("python", "print(1)")
|
||||
with pytest.raises(api.ContainerError):
|
||||
api.validate_boot("ruby", "puts 1")
|
||||
with pytest.raises(api.ContainerError):
|
||||
api.validate_boot("bash", " ")
|
||||
|
||||
|
||||
def test_boot_script_precedence_in_run_spec(env):
|
||||
inst = _ready_instance(
|
||||
env,
|
||||
boot_language="python",
|
||||
boot_script="print('boot')\n",
|
||||
boot_command="python other.py",
|
||||
autostart=False,
|
||||
)
|
||||
spec = api.run_spec_for(inst, config.CONTAINER_IMAGE)
|
||||
assert spec.command == ["python", "/app/.devplace_boot.py"]
|
||||
inst2 = _ready_instance(
|
||||
env, name="i2", boot_command="python serve.py", autostart=False
|
||||
)
|
||||
spec2 = api.run_spec_for(inst2, config.CONTAINER_IMAGE)
|
||||
assert spec2.command == ["/bin/sh", "-c", "python serve.py"]
|
||||
|
||||
|
||||
def test_materialize_boot_script_writes_file(env, tmp_path):
|
||||
workspace = tmp_path / "boot-ws"
|
||||
inst = _ready_instance(
|
||||
env, name="boot", boot_language="bash", boot_script="echo hi\n", autostart=False
|
||||
)
|
||||
store.update_instance(inst["uid"], {"workspace_dir": str(workspace)})
|
||||
inst = store.get_instance(inst["uid"])
|
||||
api.materialize_boot_script(inst)
|
||||
written = workspace / ".devplace_boot.sh"
|
||||
assert written.is_file()
|
||||
assert written.read_text() == "echo hi\n"
|
||||
|
||||
|
||||
def test_run_as_uid_drives_pravda_env(env):
|
||||
_promote_admin(env["user"]["username"])
|
||||
key = _api_key(env["user"]["username"])
|
||||
inst = _ready_instance(
|
||||
env, name="runas", run_as_uid=env["user"]["uid"], autostart=False
|
||||
)
|
||||
pravda = api.pravda_env(inst)
|
||||
assert pravda["PRAVDA_API_KEY"] == key
|
||||
assert pravda["PRAVDA_USER_UID"] == env["user"]["uid"]
|
||||
|
||||
|
||||
def test_run_as_uid_rejects_unknown_user(env):
|
||||
with pytest.raises(api.ContainerError):
|
||||
run_async(
|
||||
api.create_instance(
|
||||
env["project"], name="bad", run_as_uid="nope-uid", autostart=False
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_start_on_boot_pass_forces_running(env):
|
||||
inst = _ready_instance(env, name="boot", start_on_boot=True, autostart=False)
|
||||
assert inst["desired_state"] == store.DESIRED_STOPPED
|
||||
service = ContainerService()
|
||||
service._boot_pass(store.all_instances())
|
||||
refresh_snapshot()
|
||||
assert store.get_instance(inst["uid"])["desired_state"] == store.DESIRED_RUNNING
|
||||
|
||||
|
||||
def test_status_change_records_event(env):
|
||||
inst = _ready_instance(env, name="hist", restart_policy="never", autostart=True)
|
||||
service = ContainerService()
|
||||
run_async(service.run_once())
|
||||
refresh_snapshot()
|
||||
inst = store.get_instance(inst["uid"])
|
||||
api.set_desired_state(inst, store.DESIRED_STOPPED)
|
||||
run_async(service.run_once())
|
||||
refresh_snapshot()
|
||||
events = [e["event"] for e in store.list_events(inst["uid"])]
|
||||
assert "status_change" in events
|
||||
|
||||
|
||||
def test_bidirectional_sync_newer_wins(env, tmp_path):
|
||||
workspace = tmp_path / "sync-ws"
|
||||
workspace.mkdir()
|
||||
pid = env["project"]["uid"]
|
||||
user = env["user"]
|
||||
project_files.write_text_file(pid, user, "shared.txt", "from project\n")
|
||||
fs_only = workspace / "fromfs.txt"
|
||||
fs_only.write_text("from fs\n")
|
||||
counts = project_files.sync_dir_bidirectional(pid, str(workspace), user)
|
||||
assert counts["exported"] >= 1
|
||||
assert counts["imported"] >= 1
|
||||
assert (workspace / "shared.txt").read_text() == "from project\n"
|
||||
imported = project_files.read_file(pid, "fromfs.txt")
|
||||
assert imported["content"] == "from fs\n"
|
||||
@@ -48,6 +48,20 @@ def test_seo_report_is_public_read_only_http_action():
|
||||
assert "uid" in names
|
||||
|
||||
|
||||
def test_ui_prompt_is_local_interaction_action():
|
||||
from devplacepy.services.devii.registry import CATALOG
|
||||
|
||||
by_name = CATALOG.by_name()
|
||||
action = by_name["ui_prompt"]
|
||||
assert action.handler == "interaction"
|
||||
assert action.requires_auth is False
|
||||
names = {p.name for p in action.params}
|
||||
assert "title" in names
|
||||
assert "widgets" in names
|
||||
assert "ui_cancel" in by_name
|
||||
assert "ui_notify" in by_name
|
||||
|
||||
|
||||
def test_block_mute_tools_exist_as_http_actions():
|
||||
expected = {
|
||||
"block_user": "/block/{username}",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
@@ -0,0 +1,48 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from tests.conftest import run_async
|
||||
from devplacepy.services.devii.interaction.broker import InteractionBroker
|
||||
|
||||
|
||||
def test_answer_text_completes_open_prompt():
|
||||
captured = {}
|
||||
|
||||
async def wait_site(interaction_id, frame, timeout):
|
||||
captured["id"] = interaction_id
|
||||
outcome = broker.answer_text("13/01/1990")
|
||||
assert outcome["handled"] is True
|
||||
assert outcome["status"] == "submitted"
|
||||
return outcome["result"]
|
||||
|
||||
broker = InteractionBroker("main", wait_site=wait_site)
|
||||
result = run_async(
|
||||
broker.prompt(
|
||||
{
|
||||
"title": "When?",
|
||||
"widgets": [{"type": "date", "name": "day", "label": "Day"}],
|
||||
}
|
||||
)
|
||||
)
|
||||
assert result.status == "submitted"
|
||||
assert result.values == {"day": "13/01/1990"}
|
||||
assert result.meta.get("via") == "text"
|
||||
|
||||
|
||||
def test_answer_text_rejects_free_chat_as_date():
|
||||
async def wait_site(interaction_id, frame, timeout):
|
||||
bad = broker.answer_text("Show me number input then.")
|
||||
assert bad["status"] == "error"
|
||||
good = broker.answer_text("13/01/1990")
|
||||
return good["result"]
|
||||
|
||||
broker = InteractionBroker("main", wait_site=wait_site)
|
||||
result = run_async(
|
||||
broker.prompt(
|
||||
{
|
||||
"title": "When?",
|
||||
"widgets": [{"type": "date", "name": "day", "label": "Day"}],
|
||||
}
|
||||
)
|
||||
)
|
||||
assert result.status == "submitted"
|
||||
assert result.values == {"day": "13/01/1990"}
|
||||
@@ -0,0 +1,93 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import asyncio
|
||||
|
||||
from tests.conftest import run_async
|
||||
from devplacepy.services.devii.interaction.broker import InteractionBroker
|
||||
|
||||
|
||||
def test_broker_site_prompt_returns_submitted_values():
|
||||
async def wait_site(interaction_id, frame, timeout):
|
||||
assert frame["title"] == "Deploy?"
|
||||
assert frame["widgets"][0]["type"] == "confirm"
|
||||
return {
|
||||
"status": "submitted",
|
||||
"interaction_id": interaction_id,
|
||||
"values": {"delete": True},
|
||||
}
|
||||
|
||||
broker = InteractionBroker("main", wait_site=wait_site)
|
||||
|
||||
async def run():
|
||||
return await broker.prompt(
|
||||
{
|
||||
"title": "Deploy?",
|
||||
"widgets": [
|
||||
{
|
||||
"type": "confirm",
|
||||
"name": "delete",
|
||||
"label": "Delete",
|
||||
"default": False,
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
result = run_async(run())
|
||||
assert result.status == "submitted"
|
||||
assert result.values == {"delete": True}
|
||||
assert result.meta["adapter"] == "site-chat"
|
||||
assert result.meta["degraded"] is False
|
||||
assert broker.open_id() is None
|
||||
|
||||
|
||||
def test_broker_plain_adapter_parses_reply():
|
||||
async def present_plain(menu: str) -> str:
|
||||
assert "yes" in menu.lower() or "Delete" in menu
|
||||
return "yes"
|
||||
|
||||
broker = InteractionBroker("cli", present_plain=present_plain)
|
||||
result = run_async(
|
||||
broker.prompt(
|
||||
{
|
||||
"title": "Delete?",
|
||||
"widgets": [
|
||||
{"type": "confirm", "name": "ok", "label": "OK", "default": False}
|
||||
],
|
||||
}
|
||||
)
|
||||
)
|
||||
assert result.status == "submitted"
|
||||
assert result.values == {"ok": True}
|
||||
assert result.meta["degraded"] is True
|
||||
|
||||
|
||||
def test_broker_invalid_args_raise():
|
||||
from devplacepy.services.devii.errors import ToolInputError
|
||||
|
||||
broker = InteractionBroker("main")
|
||||
try:
|
||||
run_async(broker.prompt({"title": "x", "widgets": []}))
|
||||
assert False, "expected ToolInputError"
|
||||
except ToolInputError:
|
||||
pass
|
||||
|
||||
|
||||
def test_broker_timeout_status():
|
||||
async def wait_site(interaction_id, frame, timeout):
|
||||
raise asyncio.TimeoutError()
|
||||
|
||||
broker = InteractionBroker("main", wait_site=wait_site)
|
||||
result = run_async(
|
||||
broker.prompt(
|
||||
{
|
||||
"title": "T?",
|
||||
"timeout_sec": 1,
|
||||
"widgets": [
|
||||
{"type": "confirm", "name": "ok", "label": "OK"}
|
||||
],
|
||||
}
|
||||
)
|
||||
)
|
||||
assert result.status == "timeout"
|
||||
assert result.values == {}
|
||||
@@ -0,0 +1,61 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.services.devii.interaction.capabilities import (
|
||||
CHANNEL_CLI,
|
||||
CHANNEL_SITE_CHAT,
|
||||
CHANNEL_TELEGRAM,
|
||||
CHANNEL_UNKNOWN,
|
||||
channel_id_for_session,
|
||||
context_for,
|
||||
fragment_for,
|
||||
interactions_enabled,
|
||||
)
|
||||
|
||||
|
||||
def test_session_channel_mapping():
|
||||
assert channel_id_for_session("main") == CHANNEL_SITE_CHAT
|
||||
assert channel_id_for_session("docs") == CHANNEL_SITE_CHAT
|
||||
assert channel_id_for_session("telegram") == CHANNEL_TELEGRAM
|
||||
assert channel_id_for_session("cli") == CHANNEL_CLI
|
||||
assert channel_id_for_session("nope") == CHANNEL_UNKNOWN
|
||||
|
||||
|
||||
def test_site_context_exposes_ui_prompt(local_db):
|
||||
ctx = context_for(CHANNEL_SITE_CHAT, owner_kind="user", owner_id="u1")
|
||||
assert ctx.capabilities["rich_widgets"] is True
|
||||
assert "ui_prompt" in ctx.tools
|
||||
assert "ui_cancel" in ctx.tools
|
||||
assert "ui_notify" in ctx.tools
|
||||
assert "interactions_get" in ctx.tools
|
||||
assert "interactions_set" in ctx.tools
|
||||
|
||||
|
||||
def test_open_interaction_gates_to_cancel_only(local_db):
|
||||
ctx = context_for(
|
||||
CHANNEL_SITE_CHAT,
|
||||
open_interaction_id="ix-abc",
|
||||
owner_kind="user",
|
||||
owner_id="u1",
|
||||
)
|
||||
assert "ui_cancel" in ctx.tools
|
||||
assert "ui_prompt" not in ctx.tools
|
||||
assert "interactions_get" in ctx.tools
|
||||
|
||||
|
||||
def test_unknown_channel_has_no_ui_prompt(local_db):
|
||||
ctx = context_for(CHANNEL_UNKNOWN, owner_kind="user", owner_id="u1")
|
||||
assert "ui_prompt" not in ctx.tools
|
||||
assert ctx.capabilities["confirm"] is False
|
||||
assert "interactions_get" in ctx.tools
|
||||
|
||||
|
||||
def test_fragment_is_compact(local_db):
|
||||
text = fragment_for(context_for(CHANNEL_TELEGRAM, owner_kind="guest"))
|
||||
assert "CHANNEL: telegram" in text
|
||||
assert "TOOLS: ui_prompt" in text
|
||||
assert "INTERACTION: none" in text
|
||||
assert len(text) < 600
|
||||
|
||||
|
||||
def test_interactions_enabled_default(local_db):
|
||||
assert interactions_enabled("guest", "") is True
|
||||
@@ -0,0 +1,55 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import json
|
||||
|
||||
from tests.conftest import run_async
|
||||
from devplacepy.services.devii.interaction.broker import InteractionBroker
|
||||
from devplacepy.services.devii.interaction.controller import InteractionController
|
||||
|
||||
|
||||
def test_ui_prompt_dispatch():
|
||||
async def wait_site(interaction_id, frame, timeout):
|
||||
return {
|
||||
"status": "submitted",
|
||||
"interaction_id": interaction_id,
|
||||
"values": {"env": "staging"},
|
||||
}
|
||||
|
||||
broker = InteractionBroker("main", wait_site=wait_site)
|
||||
controller = InteractionController(broker)
|
||||
raw = run_async(
|
||||
controller.dispatch(
|
||||
"ui_prompt",
|
||||
{
|
||||
"title": "Env?",
|
||||
"widgets": [
|
||||
{
|
||||
"type": "choice",
|
||||
"name": "env",
|
||||
"label": "Env",
|
||||
"options": [
|
||||
{"value": "staging", "label": "Staging"},
|
||||
{"value": "prod", "label": "Prod"},
|
||||
],
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
)
|
||||
data = json.loads(raw)
|
||||
assert data["status"] == "submitted"
|
||||
assert data["values"]["env"] == "staging"
|
||||
|
||||
|
||||
def test_ui_notify_on_site():
|
||||
events = []
|
||||
|
||||
async def emit(payload):
|
||||
events.append(payload)
|
||||
|
||||
broker = InteractionBroker("main", emit=emit)
|
||||
controller = InteractionController(broker)
|
||||
raw = run_async(controller.dispatch("ui_notify", {"text": "Working..."}))
|
||||
data = json.loads(raw)
|
||||
assert data["status"] == "ok"
|
||||
assert events and events[0]["type"] == "status"
|
||||
@@ -0,0 +1,57 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.services.devii.interaction.markdown import (
|
||||
project_markdown,
|
||||
project_plain_menu,
|
||||
)
|
||||
from devplacepy.services.devii.interaction.schema import validate_prompt_args
|
||||
|
||||
|
||||
def _request():
|
||||
return validate_prompt_args(
|
||||
{
|
||||
"title": "Deploy to staging?",
|
||||
"description": "Ship the current build.",
|
||||
"widgets": [
|
||||
{
|
||||
"type": "choice",
|
||||
"name": "env",
|
||||
"label": "Target environment",
|
||||
"default": "staging",
|
||||
"options": [
|
||||
{"value": "production", "label": "Production"},
|
||||
{"value": "staging", "label": "Staging"},
|
||||
{"value": "dev", "label": "Development"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"name": "notes",
|
||||
"label": "Ship notes",
|
||||
"required": False,
|
||||
"max_length": 200,
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_markdown_projection_has_title_and_id():
|
||||
md = project_markdown(_request(), "ix-a1b2")
|
||||
assert "### Deploy to staging?" in md
|
||||
assert "`staging`" in md
|
||||
assert "<!-- ai-interaction:ix-a1b2 -->" in md
|
||||
assert "[Confirm]" in md
|
||||
|
||||
|
||||
def test_plain_menu_for_confirm():
|
||||
req = validate_prompt_args(
|
||||
{
|
||||
"title": "Delete?",
|
||||
"widgets": [
|
||||
{"type": "confirm", "name": "delete", "label": "Delete", "default": False}
|
||||
],
|
||||
}
|
||||
)
|
||||
text = project_plain_menu(req)
|
||||
assert "yes | no" in text.lower() or "Reply: yes" in text
|
||||
@@ -0,0 +1,117 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.services.devii.interaction.parse import parse_plain_reply
|
||||
from devplacepy.services.devii.interaction.schema import validate_prompt_args
|
||||
|
||||
|
||||
def test_parse_confirm_yes():
|
||||
req = validate_prompt_args(
|
||||
{
|
||||
"title": "Delete?",
|
||||
"widgets": [{"type": "confirm", "name": "delete", "label": "Delete"}],
|
||||
}
|
||||
)
|
||||
status, values, err = parse_plain_reply(req, "yes")
|
||||
assert status == "submitted"
|
||||
assert values == {"delete": True}
|
||||
assert err is None
|
||||
|
||||
|
||||
def test_parse_choice_by_index():
|
||||
req = validate_prompt_args(
|
||||
{
|
||||
"title": "Env?",
|
||||
"widgets": [
|
||||
{
|
||||
"type": "choice",
|
||||
"name": "env",
|
||||
"label": "Env",
|
||||
"options": [
|
||||
{"value": "production", "label": "Production"},
|
||||
{"value": "staging", "label": "Staging"},
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
status, values, err = parse_plain_reply(req, "2")
|
||||
assert status == "submitted"
|
||||
assert values == {"env": "staging"}
|
||||
|
||||
|
||||
def test_parse_cancel():
|
||||
req = validate_prompt_args(
|
||||
{
|
||||
"title": "Env?",
|
||||
"widgets": [
|
||||
{
|
||||
"type": "choice",
|
||||
"name": "env",
|
||||
"label": "Env",
|
||||
"options": [{"value": "a", "label": "A"}],
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
status, values, err = parse_plain_reply(req, "cancel")
|
||||
assert status == "cancelled"
|
||||
assert values == {}
|
||||
|
||||
|
||||
def test_parse_date_european():
|
||||
req = validate_prompt_args(
|
||||
{
|
||||
"title": "When?",
|
||||
"widgets": [{"type": "date", "name": "day", "label": "Day"}],
|
||||
}
|
||||
)
|
||||
status, values, err = parse_plain_reply(req, "13/01/1990")
|
||||
assert status == "submitted"
|
||||
assert values == {"day": "13/01/1990"}
|
||||
assert err is None
|
||||
|
||||
|
||||
def test_parse_date_iso_normalized_to_european():
|
||||
req = validate_prompt_args(
|
||||
{
|
||||
"title": "When?",
|
||||
"widgets": [{"type": "date", "name": "day", "label": "Day"}],
|
||||
}
|
||||
)
|
||||
status, values, err = parse_plain_reply(req, "1990-01-13")
|
||||
assert status == "submitted"
|
||||
assert values == {"day": "13/01/1990"}
|
||||
|
||||
|
||||
def test_parse_confirm_dutch_ja():
|
||||
req = validate_prompt_args(
|
||||
{
|
||||
"title": "Ok?",
|
||||
"widgets": [{"type": "confirm", "name": "ok", "label": "Ok"}],
|
||||
}
|
||||
)
|
||||
status, values, err = parse_plain_reply(req, "ja")
|
||||
assert status == "submitted"
|
||||
assert values == {"ok": True}
|
||||
|
||||
|
||||
def test_parse_choice_by_label():
|
||||
req = validate_prompt_args(
|
||||
{
|
||||
"title": "Lang?",
|
||||
"widgets": [
|
||||
{
|
||||
"type": "choice",
|
||||
"name": "lang",
|
||||
"label": "Language",
|
||||
"options": [
|
||||
{"value": "python", "label": "Python"},
|
||||
{"value": "rust", "label": "Rust"},
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
status, values, err = parse_plain_reply(req, "Rust")
|
||||
assert status == "submitted"
|
||||
assert values == {"lang": "rust"}
|
||||
@@ -0,0 +1,61 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.database import get_table, set_setting
|
||||
from devplacepy.services.devii.interaction import prefs
|
||||
from devplacepy.utils.accounts import register_account
|
||||
|
||||
|
||||
def _user(username="ixpref1"):
|
||||
try:
|
||||
register_account(username, f"{username}@t.dev", "secret123")
|
||||
except Exception:
|
||||
pass
|
||||
return get_table("users").find_one(username=username)
|
||||
|
||||
|
||||
def test_admin_default_applies_when_user_inherits(local_db):
|
||||
set_setting("devii_interactions_default", "1")
|
||||
user = _user("ixpref_inherit")
|
||||
get_table("users").update(
|
||||
{"uid": user["uid"], "interactions_enabled": -1}, ["uid"]
|
||||
)
|
||||
assert prefs.effective_for("user", user["uid"]) is True
|
||||
snap = prefs.snapshot("user", user["uid"])
|
||||
assert snap["source"] == "default"
|
||||
assert snap["override"] is None
|
||||
|
||||
|
||||
def test_user_override_off(local_db):
|
||||
set_setting("devii_interactions_default", "1")
|
||||
user = _user("ixpref_off")
|
||||
prefs.set_user_pref(user["uid"], False)
|
||||
assert prefs.effective_for("user", user["uid"]) is False
|
||||
snap = prefs.snapshot("user", user["uid"])
|
||||
assert snap["source"] == "user"
|
||||
assert snap["override"] is False
|
||||
assert snap["default"] is True
|
||||
|
||||
|
||||
def test_user_override_on_when_default_off(local_db):
|
||||
set_setting("devii_interactions_default", "0")
|
||||
user = _user("ixpref_on")
|
||||
prefs.set_user_pref(user["uid"], True)
|
||||
assert prefs.effective_for("user", user["uid"]) is True
|
||||
assert prefs.admin_default() is False
|
||||
|
||||
|
||||
def test_reset_returns_to_default(local_db):
|
||||
set_setting("devii_interactions_default", "0")
|
||||
user = _user("ixpref_reset")
|
||||
prefs.set_user_pref(user["uid"], True)
|
||||
snap = prefs.set_user_pref(user["uid"], None)
|
||||
assert snap["source"] == "default"
|
||||
assert snap["enabled"] is False
|
||||
assert get_table("users").find_one(uid=user["uid"])["interactions_enabled"] == -1
|
||||
|
||||
|
||||
def test_guest_uses_admin_default(local_db):
|
||||
set_setting("devii_interactions_default", "0")
|
||||
assert prefs.effective_for("guest", "cookie") is False
|
||||
set_setting("devii_interactions_default", "1")
|
||||
assert prefs.effective_for("guest", "cookie") is True
|
||||
@@ -0,0 +1,96 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import pytest
|
||||
|
||||
from devplacepy.services.devii.errors import ToolInputError
|
||||
from devplacepy.services.devii.interaction.schema import (
|
||||
validate_prompt_args,
|
||||
)
|
||||
|
||||
|
||||
def test_valid_confirm_prompt():
|
||||
req = validate_prompt_args(
|
||||
{
|
||||
"title": "Delete archive row?",
|
||||
"description": "Remote files are untouched.",
|
||||
"widgets": [
|
||||
{
|
||||
"type": "confirm",
|
||||
"name": "delete",
|
||||
"label": "Delete archive row",
|
||||
"default": False,
|
||||
}
|
||||
],
|
||||
"submit_label": "Delete",
|
||||
"cancel_label": "Keep",
|
||||
}
|
||||
)
|
||||
assert req.title.startswith("Delete")
|
||||
assert req.widgets[0].type == "confirm"
|
||||
assert req.widgets[0].name == "delete"
|
||||
|
||||
|
||||
def test_choice_requires_options():
|
||||
with pytest.raises(ToolInputError):
|
||||
validate_prompt_args(
|
||||
{
|
||||
"title": "Env?",
|
||||
"widgets": [{"type": "choice", "name": "env", "label": "Env"}],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_rejects_empty_widgets():
|
||||
with pytest.raises(ToolInputError):
|
||||
validate_prompt_args({"title": "x", "widgets": []})
|
||||
|
||||
|
||||
def test_rejects_bad_option_value():
|
||||
with pytest.raises(ToolInputError):
|
||||
validate_prompt_args(
|
||||
{
|
||||
"title": "Pick",
|
||||
"widgets": [
|
||||
{
|
||||
"type": "choice",
|
||||
"name": "x",
|
||||
"label": "X",
|
||||
"options": [{"value": "BAD VALUE!", "label": "Bad"}],
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_group_nesting_limit():
|
||||
leaf = {
|
||||
"type": "text",
|
||||
"name": "a",
|
||||
"label": "A",
|
||||
"required": False,
|
||||
}
|
||||
deep = {
|
||||
"type": "group",
|
||||
"label": "G1",
|
||||
"widgets": [
|
||||
{
|
||||
"type": "group",
|
||||
"label": "G2",
|
||||
"widgets": [
|
||||
{
|
||||
"type": "group",
|
||||
"label": "G3",
|
||||
"widgets": [
|
||||
{
|
||||
"type": "group",
|
||||
"label": "G4",
|
||||
"widgets": [leaf],
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
with pytest.raises(ToolInputError):
|
||||
validate_prompt_args({"title": "Deep", "widgets": [deep]})
|
||||
@@ -41,8 +41,34 @@ def test_main_channel_keeps_full_catalog_and_behavior(local_db):
|
||||
main = _session("main", "sess-main")
|
||||
names = _tool_names(main)
|
||||
assert "search_docs" in names
|
||||
assert "ui_prompt" in names
|
||||
assert "ui_cancel" in names
|
||||
assert len(names) > 1
|
||||
assert "TRUTH RULES AND BEHAVIOR" in main._compose_system_prompt()
|
||||
prompt = main._compose_system_prompt()
|
||||
assert "TRUTH RULES AND BEHAVIOR" in prompt
|
||||
assert "CHANNEL: site-chat" in prompt
|
||||
assert "Interactive asks (CA-IWP)" in prompt
|
||||
|
||||
|
||||
def test_docs_channel_excludes_ui_prompt(local_db):
|
||||
docs = _session("docs", "sess-docs-ui")
|
||||
assert "ui_prompt" not in _tool_names(docs)
|
||||
|
||||
|
||||
def test_telegram_channel_includes_ui_prompt(local_db):
|
||||
tg = _session("telegram", "sess-tg-ui")
|
||||
names = _tool_names(tg)
|
||||
assert "ui_prompt" in names
|
||||
prompt = tg._compose_system_prompt()
|
||||
assert "CHANNEL: telegram" in prompt
|
||||
|
||||
|
||||
def test_guest_main_has_ui_prompt_without_pref_tools(local_db):
|
||||
main = _session("main", "sess-guest-ui")
|
||||
names = _tool_names(main)
|
||||
assert "ui_prompt" in names
|
||||
assert "interactions_get" not in names
|
||||
assert "interactions_set" not in names
|
||||
|
||||
|
||||
def test_docs_channel_does_not_start_scheduler(local_db):
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.services.devii.text import normalize_newlines
|
||||
|
||||
|
||||
def test_normalize_literal_backslash_n():
|
||||
raw = "[compacted earlier turns]\n\nHello\\n\\n## Title\\n\\n| A | B |"
|
||||
out = normalize_newlines(raw)
|
||||
assert "\\n" not in out or out.count("\n") > raw.count("\n")
|
||||
assert "\n\n## Title\n\n" in out
|
||||
assert out.startswith("[compacted earlier turns]\n\nHello\n")
|
||||
|
||||
|
||||
def test_normalize_leaves_real_newlines():
|
||||
raw = "line one\nline two\nline three"
|
||||
assert normalize_newlines(raw) == raw
|
||||
|
||||
|
||||
def test_normalize_double_escaped():
|
||||
raw = "a\\\\n\\\\nb"
|
||||
out = normalize_newlines(raw)
|
||||
assert "\n" in out
|
||||
assert out == "a\n\nb"
|
||||
@@ -0,0 +1,174 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import base64
|
||||
import io
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
from devplacepy.db_client import get_award_usage, get_table, init_db
|
||||
from devplacepy.db_client import recompute_user_award_stats
|
||||
from devplacepy.services.jobs.award_service import AwardService
|
||||
from devplacepy.utils import generate_uid, make_combined_slug
|
||||
from tests.conftest import run_async
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _db(local_db, tmp_path, monkeypatch):
|
||||
init_db()
|
||||
monkeypatch.setattr("devplacepy.attachments.ATTACHMENTS_DIR", tmp_path / "attachments")
|
||||
yield
|
||||
|
||||
|
||||
def _png_bytes():
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGBA", (64, 64), (40, 80, 120, 255)).save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, payload, headers=None):
|
||||
self._payload = payload
|
||||
self.headers = headers or {}
|
||||
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, *args, **kwargs):
|
||||
self._png = _png_bytes()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
return False
|
||||
|
||||
def post(self, url, json=None, headers=None):
|
||||
encoded = base64.b64encode(self._png).decode("ascii")
|
||||
return _FakeResponse(
|
||||
{"data": [{"b64_json": encoded}]},
|
||||
headers={
|
||||
"X-Gateway-Cost-USD": "0.001",
|
||||
"X-Gateway-Prompt-Tokens": "10",
|
||||
"X-Gateway-Completion-Tokens": "0",
|
||||
"X-Gateway-Total-Tokens": "10",
|
||||
"X-Gateway-Upstream-Latency-Ms": "100",
|
||||
"X-Gateway-Total-Latency-Ms": "120",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _seed_pending(giver_uid, receiver_uid, description="Nice job"):
|
||||
uid = generate_uid()
|
||||
slug = make_combined_slug(description, uid)
|
||||
get_table("awards").insert(
|
||||
{
|
||||
"uid": uid,
|
||||
"slug": slug,
|
||||
"description": description,
|
||||
"giver_uid": giver_uid,
|
||||
"receiver_uid": receiver_uid,
|
||||
"attachment_uid_512": "",
|
||||
"attachment_uid_256": "",
|
||||
"attachment_uid_64": "",
|
||||
"generated_at": None,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"job_uid": "",
|
||||
"deleted_at": None,
|
||||
"deleted_by": None,
|
||||
}
|
||||
)
|
||||
return uid, slug
|
||||
|
||||
|
||||
def _user(prefix):
|
||||
uid = generate_uid()
|
||||
get_table("users").insert(
|
||||
{
|
||||
"uid": uid,
|
||||
"username": f"{prefix}_{uid[:8]}",
|
||||
"email": f"{uid[:8]}@t.dev",
|
||||
"api_key": generate_uid(),
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
)
|
||||
return uid
|
||||
|
||||
|
||||
def _job(award_uid, giver_uid, receiver_uid, api_key):
|
||||
return {
|
||||
"uid": generate_uid(),
|
||||
"payload": {
|
||||
"award_uid": award_uid,
|
||||
"giver_uid": giver_uid,
|
||||
"receiver_uid": receiver_uid,
|
||||
"description": "Nice job",
|
||||
"api_key": api_key,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_process_finalizes_award(monkeypatch):
|
||||
monkeypatch.setattr("devplacepy.services.jobs.award_service.stealth.stealth_sync_client", _FakeClient)
|
||||
giver = _user("giver")
|
||||
receiver = _user("recv")
|
||||
api_key = get_table("users").find_one(uid=giver)["api_key"]
|
||||
award_uid, _ = _seed_pending(giver, receiver)
|
||||
run_async(AwardService().process(_job(award_uid, giver, receiver, api_key)))
|
||||
row = get_table("awards").find_one(uid=award_uid)
|
||||
assert row.get("generated_at")
|
||||
assert row.get("attachment_uid_512")
|
||||
assert row.get("attachment_uid_256")
|
||||
assert row.get("attachment_uid_64")
|
||||
recompute_user_award_stats(receiver)
|
||||
user = get_table("users").find_one(uid=receiver)
|
||||
assert user.get("award_count") == 1
|
||||
|
||||
|
||||
def test_process_is_idempotent(monkeypatch):
|
||||
monkeypatch.setattr("devplacepy.services.jobs.award_service.stealth.stealth_sync_client", _FakeClient)
|
||||
giver = _user("giver2")
|
||||
receiver = _user("recv2")
|
||||
api_key = get_table("users").find_one(uid=giver)["api_key"]
|
||||
award_uid, _ = _seed_pending(giver, receiver)
|
||||
job = _job(award_uid, giver, receiver, api_key)
|
||||
svc = AwardService()
|
||||
run_async(svc.process(job))
|
||||
first = get_table("awards").find_one(uid=award_uid)
|
||||
run_async(svc.process(job))
|
||||
second = get_table("awards").find_one(uid=award_uid)
|
||||
assert first["generated_at"] == second["generated_at"]
|
||||
assert first["attachment_uid_512"] == second["attachment_uid_512"]
|
||||
|
||||
|
||||
def test_process_skips_soft_deleted_award(monkeypatch):
|
||||
monkeypatch.setattr("devplacepy.services.jobs.award_service.stealth.stealth_sync_client", _FakeClient)
|
||||
giver = _user("giver3")
|
||||
receiver = _user("recv3")
|
||||
api_key = get_table("users").find_one(uid=giver)["api_key"]
|
||||
award_uid, _ = _seed_pending(giver, receiver)
|
||||
get_table("awards").update(
|
||||
{"uid": award_uid, "deleted_at": datetime.now(timezone.utc).isoformat(), "deleted_by": giver},
|
||||
["uid"],
|
||||
)
|
||||
result = run_async(AwardService().process(_job(award_uid, giver, receiver, api_key)))
|
||||
assert result.get("skipped") is True
|
||||
assert not get_table("awards").find_one(uid=award_uid).get("generated_at")
|
||||
|
||||
|
||||
def test_award_usage_accumulates_from_gateway_headers(monkeypatch):
|
||||
monkeypatch.setattr("devplacepy.services.jobs.award_service.stealth.stealth_sync_client", _FakeClient)
|
||||
get_table("award_usage").delete()
|
||||
giver = _user("giver4")
|
||||
receiver = _user("recv4")
|
||||
api_key = get_table("users").find_one(uid=giver)["api_key"]
|
||||
award_uid, _ = _seed_pending(giver, receiver)
|
||||
run_async(AwardService().process(_job(award_uid, giver, receiver, api_key)))
|
||||
usage = get_award_usage()
|
||||
assert usage["calls"] >= 1
|
||||
assert usage["cost_usd"] > 0
|
||||
@@ -107,6 +107,7 @@ def test_model_is_forced(local_db, monkeypatch):
|
||||
cfg,
|
||||
("guest", "test"),
|
||||
"test",
|
||||
"default",
|
||||
)
|
||||
)
|
||||
assert rt._client.calls[-1][1]["model"] == "deepseek-chat"
|
||||
@@ -143,6 +144,7 @@ def test_model_route_overrides_upstream(local_db, monkeypatch):
|
||||
cfg,
|
||||
("guest", "test"),
|
||||
"test",
|
||||
"default",
|
||||
)
|
||||
)
|
||||
url, body = rt._client.calls[-1]
|
||||
@@ -169,7 +171,7 @@ def test_vision_rewrites_image_to_text(local_db, monkeypatch):
|
||||
],
|
||||
}
|
||||
]
|
||||
run_async(rt.handle_chat({"messages": msgs}, cfg, ("guest", "test"), "test"))
|
||||
run_async(rt.handle_chat({"messages": msgs}, cfg, ("guest", "test"), "test", "default"))
|
||||
sent = rt._client.calls[-1][1]["messages"][0]["content"]
|
||||
assert isinstance(sent, str) and "vision" in sent.lower()
|
||||
|
||||
@@ -185,6 +187,7 @@ def test_streaming_emits_sse(local_db, monkeypatch):
|
||||
cfg,
|
||||
("guest", "test"),
|
||||
"test",
|
||||
"default",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -282,6 +285,7 @@ def test_embeddings_remaps_alias_to_configured_model(local_db, monkeypatch):
|
||||
cfg,
|
||||
("guest", "test"),
|
||||
"test",
|
||||
"default",
|
||||
)
|
||||
)
|
||||
assert rt._client.calls[-1][1]["model"] == "qwen/qwen3-embedding-8b"
|
||||
@@ -302,6 +306,7 @@ def test_embeddings_success_records_ledger(local_db, monkeypatch):
|
||||
cfg,
|
||||
("guest", "ledger_probe"),
|
||||
"test",
|
||||
"default",
|
||||
)
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
@@ -325,7 +330,7 @@ def test_embeddings_disabled_returns_503(local_db, monkeypatch):
|
||||
rt = svc.runtime()
|
||||
resp = run_async(
|
||||
rt.handle_embeddings(
|
||||
{"input": "hello"}, cfg, ("guest", "test"), "test"
|
||||
{"input": "hello"}, cfg, ("guest", "test"), "test", "default"
|
||||
)
|
||||
)
|
||||
assert resp.status_code == 503
|
||||
@@ -372,6 +377,7 @@ def test_images_remaps_alias_to_configured_model(local_db, monkeypatch):
|
||||
cfg,
|
||||
("guest", "test"),
|
||||
"test",
|
||||
"default",
|
||||
)
|
||||
)
|
||||
assert rt._client.calls[-1][1]["model"] == "black-forest-labs/flux.2-pro"
|
||||
@@ -411,6 +417,7 @@ def test_image_route_overrides_upstream(local_db, monkeypatch):
|
||||
cfg,
|
||||
("guest", "test"),
|
||||
"test",
|
||||
"default",
|
||||
)
|
||||
)
|
||||
url, body = rt._client.calls[-1]
|
||||
@@ -435,6 +442,7 @@ def test_images_success_records_ledger(local_db, monkeypatch):
|
||||
cfg,
|
||||
("guest", "img_ledger"),
|
||||
"test",
|
||||
"default",
|
||||
)
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
@@ -459,7 +467,7 @@ def test_images_disabled_returns_503(local_db, monkeypatch):
|
||||
rt = svc.runtime()
|
||||
resp = run_async(
|
||||
rt.handle_images(
|
||||
{"prompt": "badge"}, cfg, ("guest", "test"), "test"
|
||||
{"prompt": "badge"}, cfg, ("guest", "test"), "test", "default"
|
||||
)
|
||||
)
|
||||
assert resp.status_code == 503
|
||||
@@ -489,3 +497,271 @@ def test_compute_cost_embed_branch():
|
||||
assert output_cost == 0.0
|
||||
assert abs(total - 0.01) < 1e-9
|
||||
assert abs(input_cost - 0.01) < 1e-9
|
||||
|
||||
|
||||
def test_app_reference_stored_in_ledger_for_chat(local_db, monkeypatch):
|
||||
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeClient_openai_gateway)
|
||||
svc = GatewayService()
|
||||
cfg = svc.effective_config()
|
||||
cfg["gateway_force_model"] = True
|
||||
cfg["gateway_model"] = "deepseek-chat"
|
||||
rt = svc.runtime()
|
||||
run_async(
|
||||
rt.handle_chat(
|
||||
{"messages": [{"role": "user", "content": "ref_test"}]},
|
||||
cfg,
|
||||
("guest", "ref_probe"),
|
||||
"test-ua",
|
||||
"my-custom-app",
|
||||
)
|
||||
)
|
||||
row = get_table("gateway_usage_ledger").find_one(owner_id="ref_probe")
|
||||
assert row is not None
|
||||
assert row["app_reference"] == "my-custom-app"
|
||||
assert row["backend"] == "chat"
|
||||
|
||||
|
||||
def test_app_reference_defaults_when_not_passed(local_db, monkeypatch):
|
||||
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeClient_openai_gateway)
|
||||
svc = GatewayService()
|
||||
cfg = svc.effective_config()
|
||||
cfg["gateway_force_model"] = True
|
||||
cfg["gateway_model"] = "deepseek-chat"
|
||||
rt = svc.runtime()
|
||||
run_async(
|
||||
rt.handle_chat(
|
||||
{"messages": [{"role": "user", "content": "default_test"}]},
|
||||
cfg,
|
||||
("guest", "default_probe"),
|
||||
"test-ua",
|
||||
"default",
|
||||
)
|
||||
)
|
||||
row = get_table("gateway_usage_ledger").find_one(owner_id="default_probe")
|
||||
assert row is not None
|
||||
assert row["app_reference"] == "default"
|
||||
|
||||
|
||||
def test_app_reference_stored_in_ledger_for_embeddings(local_db, monkeypatch):
|
||||
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeEmbedClient_openai_gateway)
|
||||
svc = GatewayService()
|
||||
cfg = svc.effective_config()
|
||||
cfg["gateway_embed_enabled"] = True
|
||||
cfg["gateway_embed_model"] = "qwen/qwen3-embedding-8b"
|
||||
rt = svc.runtime()
|
||||
run_async(
|
||||
rt.handle_embeddings(
|
||||
{"input": "hello"},
|
||||
cfg,
|
||||
("guest", "embed_ref"),
|
||||
"test-ua",
|
||||
"devplace-embed-test-v-1-0-0",
|
||||
)
|
||||
)
|
||||
row = get_table("gateway_usage_ledger").find_one(owner_id="embed_ref")
|
||||
assert row is not None
|
||||
assert row["app_reference"] == "devplace-embed-test-v-1-0-0"
|
||||
assert row["backend"] == "embed"
|
||||
|
||||
|
||||
def test_app_reference_stored_in_ledger_for_images(local_db, monkeypatch):
|
||||
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeImageClient_openai_gateway)
|
||||
svc = GatewayService()
|
||||
cfg = svc.effective_config()
|
||||
cfg["gateway_image_enabled"] = True
|
||||
cfg["gateway_image_model"] = "black-forest-labs/flux.2-pro"
|
||||
rt = svc.runtime()
|
||||
run_async(
|
||||
rt.handle_images(
|
||||
{"prompt": "ref badge"},
|
||||
cfg,
|
||||
("guest", "img_ref"),
|
||||
"test-ua",
|
||||
"devplace-image-test-v-1-0-0",
|
||||
)
|
||||
)
|
||||
row = get_table("gateway_usage_ledger").find_one(owner_id="img_ref")
|
||||
assert row is not None
|
||||
assert row["app_reference"] == "devplace-image-test-v-1-0-0"
|
||||
assert row["backend"] == "image"
|
||||
|
||||
|
||||
def test_app_reference_column_exists(local_db):
|
||||
db = get_table("gateway_usage_ledger").db
|
||||
if "gateway_usage_ledger" not in db.tables:
|
||||
return
|
||||
rows = list(db.query("PRAGMA table_info('gateway_usage_ledger')"))
|
||||
column_names = [r["name"] for r in rows]
|
||||
assert "app_reference" in column_names
|
||||
|
||||
|
||||
def test_chat_unknown_model_falls_back_to_default(local_db, monkeypatch):
|
||||
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeClient_openai_gateway)
|
||||
svc = GatewayService()
|
||||
cfg = svc.effective_config()
|
||||
cfg["gateway_force_model"] = False
|
||||
cfg["gateway_model"] = "deepseek-v4-flash"
|
||||
rt = svc.runtime()
|
||||
run_async(
|
||||
rt.handle_chat(
|
||||
{"model": "nonexistent-model-v99", "messages": [{"role": "user", "content": "hi"}]},
|
||||
cfg,
|
||||
("guest", "fallback_test_chat"),
|
||||
"test",
|
||||
"default",
|
||||
)
|
||||
)
|
||||
assert rt._client.calls[-1][1]["model"] == "deepseek-v4-flash"
|
||||
|
||||
|
||||
def test_embeddings_unknown_model_falls_back_to_default(local_db, monkeypatch):
|
||||
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeEmbedClient_openai_gateway)
|
||||
svc = GatewayService()
|
||||
cfg = svc.effective_config()
|
||||
cfg["gateway_force_model"] = False
|
||||
cfg["gateway_embed_enabled"] = True
|
||||
cfg["gateway_embed_model"] = "qwen/qwen3-embedding-8b"
|
||||
rt = svc.runtime()
|
||||
run_async(
|
||||
rt.handle_embeddings(
|
||||
{"model": "nonexistent-embed-model", "input": "hello"},
|
||||
cfg,
|
||||
("guest", "fallback_test_embed"),
|
||||
"test",
|
||||
"default",
|
||||
)
|
||||
)
|
||||
assert rt._client.calls[-1][1]["model"] == "qwen/qwen3-embedding-8b"
|
||||
|
||||
|
||||
def test_images_unknown_model_falls_back_to_default(local_db, monkeypatch):
|
||||
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeImageClient_openai_gateway)
|
||||
svc = GatewayService()
|
||||
cfg = svc.effective_config()
|
||||
cfg["gateway_force_model"] = False
|
||||
cfg["gateway_image_enabled"] = True
|
||||
cfg["gateway_image_model"] = "black-forest-labs/flux.2-pro"
|
||||
rt = svc.runtime()
|
||||
run_async(
|
||||
rt.handle_images(
|
||||
{"model": "nonexistent-image-model", "prompt": "test badge"},
|
||||
cfg,
|
||||
("guest", "fallback_test_img"),
|
||||
"test",
|
||||
"default",
|
||||
)
|
||||
)
|
||||
assert rt._client.calls[-1][1]["model"] == "black-forest-labs/flux.2-pro"
|
||||
|
||||
|
||||
class FakeClientWithUsage_openai_gateway(FakeClient_openai_gateway):
|
||||
async def send(self, request):
|
||||
self.calls.append((request.url, request.json_body))
|
||||
body = request.json_body or {}
|
||||
return FakeResp_openai_gateway(
|
||||
payload={
|
||||
"id": "x",
|
||||
"model": body.get("model"),
|
||||
"choices": [{"message": {"content": "hi there"}}],
|
||||
"usage": {"prompt_tokens": 7, "completion_tokens": 3, "total_tokens": 10},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_stream_options_stripped_from_upstream(local_db, monkeypatch):
|
||||
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeClient_openai_gateway)
|
||||
svc = GatewayService()
|
||||
cfg = svc.effective_config()
|
||||
rt = svc.runtime()
|
||||
run_async(
|
||||
rt.handle_chat(
|
||||
{
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
"stream": True,
|
||||
"stream_options": {"include_usage": True},
|
||||
},
|
||||
cfg,
|
||||
("guest", "test"),
|
||||
"test",
|
||||
"default",
|
||||
)
|
||||
)
|
||||
upstream_payload = rt._client.calls[-1][1]
|
||||
assert "stream_options" not in upstream_payload
|
||||
assert upstream_payload["stream"] is False
|
||||
|
||||
|
||||
def test_include_usage_emits_usage_chunk(local_db, monkeypatch):
|
||||
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeClientWithUsage_openai_gateway)
|
||||
svc = GatewayService()
|
||||
cfg = svc.effective_config()
|
||||
rt = svc.runtime()
|
||||
resp = run_async(
|
||||
rt.handle_chat(
|
||||
{
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
"stream": True,
|
||||
"stream_options": {"include_usage": True},
|
||||
},
|
||||
cfg,
|
||||
("guest", "test"),
|
||||
"test",
|
||||
"default",
|
||||
)
|
||||
)
|
||||
|
||||
async def drain():
|
||||
out = []
|
||||
async for chunk in resp.body_iterator:
|
||||
out.append(chunk if isinstance(chunk, str) else chunk.decode())
|
||||
return "".join(out)
|
||||
|
||||
body = run_async(drain())
|
||||
assert '"usage"' in body
|
||||
assert '"total_tokens": 10' in body
|
||||
assert "[DONE]" in body
|
||||
|
||||
|
||||
def test_no_usage_chunk_without_include_usage(local_db, monkeypatch):
|
||||
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeClientWithUsage_openai_gateway)
|
||||
svc = GatewayService()
|
||||
cfg = svc.effective_config()
|
||||
rt = svc.runtime()
|
||||
resp = run_async(
|
||||
rt.handle_chat(
|
||||
{"messages": [{"role": "user", "content": "hi"}], "stream": True},
|
||||
cfg,
|
||||
("guest", "test"),
|
||||
"test",
|
||||
"default",
|
||||
)
|
||||
)
|
||||
|
||||
async def drain():
|
||||
out = []
|
||||
async for chunk in resp.body_iterator:
|
||||
out.append(chunk if isinstance(chunk, str) else chunk.decode())
|
||||
return "".join(out)
|
||||
|
||||
body = run_async(drain())
|
||||
assert '"usage"' not in body
|
||||
assert "[DONE]" in body
|
||||
|
||||
|
||||
def test_models_endpoint_publishes_molodetz(local_db):
|
||||
from devplacepy.services.openai_gateway import routing
|
||||
|
||||
routing.seed_default_deepseek_routes()
|
||||
try:
|
||||
svc = GatewayService()
|
||||
resp = svc._models_response()
|
||||
payload = json.loads(resp.body.decode())
|
||||
ids = {m["id"] for m in payload["data"]}
|
||||
assert payload["object"] == "list"
|
||||
assert {"molodetz", "molodetz-pro"} <= ids
|
||||
for model in payload["data"]:
|
||||
assert model["object"] == "model"
|
||||
assert model["owned_by"] == "molodetz"
|
||||
finally:
|
||||
routing.model_store.remove("molodetz")
|
||||
routing.model_store.remove("molodetz-pro")
|
||||
|
||||
@@ -280,3 +280,19 @@ def test_seed_default_deepseek_routes_is_idempotent_and_preserves_customization(
|
||||
context_window=1_048_576,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_seed_publishes_molodetz_aliases(local_db):
|
||||
r.seed_default_deepseek_routes()
|
||||
try:
|
||||
molodetz = r.model_store.resolve("molodetz", "chat")
|
||||
molodetz_pro = r.model_store.resolve("molodetz-pro", "chat")
|
||||
assert molodetz is not None
|
||||
assert molodetz.target_model == "deepseek-v4-flash"
|
||||
assert molodetz_pro is not None
|
||||
assert molodetz_pro.target_model == "deepseek-v4-pro"
|
||||
sources = {row["source_model"] for row in r.model_store.list()}
|
||||
assert {"molodetz", "molodetz-pro"} <= sources
|
||||
finally:
|
||||
r.model_store.remove("molodetz")
|
||||
r.model_store.remove("molodetz-pro")
|
||||
|
||||
@@ -219,3 +219,32 @@ def test_compute_cost_native_upstream_cost_unaffected_by_tiering():
|
||||
total, _, _, native = compute_cost({"cost": 0.05}, norm, tiered, "chat")
|
||||
assert native is True
|
||||
assert total == 0.05
|
||||
|
||||
|
||||
def test_validate_app_reference_accepts_valid_slugs():
|
||||
from devplacepy.services.openai_gateway.service import _validate_app_reference
|
||||
|
||||
assert _validate_app_reference("devplace-devii-v-1-0-0") == "devplace-devii-v-1-0-0"
|
||||
assert _validate_app_reference("my-app") == "my-app"
|
||||
assert _validate_app_reference("a") == "a"
|
||||
assert _validate_app_reference("abcdefghijklmnopqrstuvwxyz0123") == "abcdefghijklmnopqrstuvwxyz0123"
|
||||
assert _validate_app_reference("test_app.release-2") == "test_app.release-2"
|
||||
|
||||
|
||||
def test_validate_app_reference_rejects_invalid():
|
||||
from devplacepy.services.openai_gateway.service import _validate_app_reference
|
||||
|
||||
assert _validate_app_reference("") == "default"
|
||||
assert _validate_app_reference(" ") == "default"
|
||||
assert _validate_app_reference("hello world") == "default"
|
||||
assert _validate_app_reference("name@domain") == "default"
|
||||
assert _validate_app_reference("name/domain") == "default"
|
||||
assert _validate_app_reference(None) == "default"
|
||||
assert _validate_app_reference("abcdefghijklmnopqrstuvwxyz01234") == "default"
|
||||
|
||||
|
||||
def test_validate_app_reference_strips_whitespace():
|
||||
from devplacepy.services.openai_gateway.service import _validate_app_reference
|
||||
|
||||
assert _validate_app_reference(" my-app ") == "my-app"
|
||||
assert _validate_app_reference("\tdevplace\t") == "devplace"
|
||||
|
||||
@@ -38,6 +38,32 @@ def test_private_text_message_emitted():
|
||||
assert messages[0]["chat_id"] == 42 and messages[0]["text"] == "hello"
|
||||
|
||||
|
||||
def test_callback_query_emitted():
|
||||
backend = FakeTelegramBackend()
|
||||
worker, frames = _worker(backend)
|
||||
_run(
|
||||
worker.process_update(
|
||||
{
|
||||
"update_id": 2,
|
||||
"callback_query": {
|
||||
"id": "cb1",
|
||||
"from": {"id": 7},
|
||||
"data": "i=del1;k=env;v=stg",
|
||||
"message": {
|
||||
"message_id": 9,
|
||||
"chat": {"id": 42, "type": "private"},
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
callbacks = [f for f in frames if f["type"] == "callback"]
|
||||
assert len(callbacks) == 1
|
||||
assert callbacks[0]["chat_id"] == 42
|
||||
assert callbacks[0]["data"] == "i=del1;k=env;v=stg"
|
||||
assert callbacks[0]["callback_query_id"] == "cb1"
|
||||
|
||||
|
||||
def test_group_message_ignored():
|
||||
backend = FakeTelegramBackend()
|
||||
worker, frames = _worker(backend)
|
||||
@@ -78,14 +104,14 @@ def test_photo_becomes_data_uri():
|
||||
|
||||
def test_html_send_falls_back_to_plain_on_entity_error():
|
||||
class EntityBackend(FakeTelegramBackend):
|
||||
async def send_message(self, chat_id, text, parse_mode):
|
||||
async def send_message(self, chat_id, text, parse_mode, reply_markup=None):
|
||||
if parse_mode:
|
||||
return {
|
||||
"ok": False,
|
||||
"error_code": 400,
|
||||
"description": "Bad Request: can't parse entities",
|
||||
}
|
||||
return await super().send_message(chat_id, text, None)
|
||||
return await super().send_message(chat_id, text, None, reply_markup=reply_markup)
|
||||
|
||||
backend = EntityBackend()
|
||||
worker, frames = _worker(backend)
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from devplacepy.db_client import get_table
|
||||
from devplacepy.utils import generate_uid
|
||||
|
||||
|
||||
def _now():
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _make_user(local_db, **overrides):
|
||||
uid = generate_uid()
|
||||
row = {
|
||||
"uid": uid,
|
||||
"username": f"cpd_{uid[:8]}",
|
||||
"email": f"cpd_{uid[:8]}@t.dev",
|
||||
"api_key": generate_uid(),
|
||||
"role": "Member",
|
||||
"is_active": True,
|
||||
"created_at": _now(),
|
||||
"bio": "",
|
||||
}
|
||||
row.update(overrides)
|
||||
get_table("users").insert(row)
|
||||
return row
|
||||
|
||||
|
||||
def _make_post(local_db, user, **overrides):
|
||||
from devplacepy.utils import make_combined_slug
|
||||
|
||||
uid = generate_uid()
|
||||
row = {
|
||||
"uid": uid,
|
||||
"user_uid": user["uid"],
|
||||
"title": "Compound test post",
|
||||
"content": "Body of the compound test post.",
|
||||
"topic": "devlog",
|
||||
"slug": make_combined_slug("Compound test post", uid),
|
||||
"stars": 0,
|
||||
"created_at": _now(),
|
||||
"deleted_at": None,
|
||||
"deleted_by": None,
|
||||
}
|
||||
row.update(overrides)
|
||||
get_table("posts").insert(row)
|
||||
return row
|
||||
|
||||
|
||||
def _make_project(local_db, user, **overrides):
|
||||
from devplacepy.utils import make_combined_slug
|
||||
|
||||
uid = generate_uid()
|
||||
row = {
|
||||
"uid": uid,
|
||||
"user_uid": user["uid"],
|
||||
"title": "Compound test project",
|
||||
"description": "",
|
||||
"slug": make_combined_slug("Compound test project", uid),
|
||||
"stars": 0,
|
||||
"is_private": 0,
|
||||
"created_at": _now(),
|
||||
"deleted_at": None,
|
||||
"deleted_by": None,
|
||||
}
|
||||
row.update(overrides)
|
||||
get_table("projects").insert(row)
|
||||
return row
|
||||
|
||||
|
||||
def _make_gist(local_db, user, **overrides):
|
||||
from devplacepy.utils import make_combined_slug
|
||||
|
||||
uid = generate_uid()
|
||||
row = {
|
||||
"uid": uid,
|
||||
"user_uid": user["uid"],
|
||||
"title": "Compound test gist",
|
||||
"description": "",
|
||||
"source_code": "print('hi')",
|
||||
"language": "python",
|
||||
"slug": make_combined_slug("Compound test gist", uid),
|
||||
"stars": 0,
|
||||
"created_at": _now(),
|
||||
"deleted_at": None,
|
||||
"deleted_by": None,
|
||||
}
|
||||
row.update(overrides)
|
||||
get_table("gists").insert(row)
|
||||
return row
|
||||
|
||||
|
||||
# Section 5.3 page compounds actually wired to a caller (db_client.build_*)
|
||||
# and consumed by a real web route. Each assertion checks the template
|
||||
# context keys the corresponding route/schema depends on.
|
||||
|
||||
|
||||
def test_build_feed_page_shape(local_db):
|
||||
from devplacepy_services.database.compounds_page import build_feed_page
|
||||
|
||||
user = _make_user(local_db)
|
||||
post = _make_post(local_db, user)
|
||||
|
||||
result = build_feed_page(user=user)
|
||||
|
||||
for key in ("posts", "next_cursor", "stats", "top_authors", "daily_topic", "current_tab"):
|
||||
assert key in result
|
||||
uids = [item["post"]["uid"] for item in result["posts"]]
|
||||
assert post["uid"] in uids
|
||||
item = next(i for i in result["posts"] if i["post"]["uid"] == post["uid"])
|
||||
for key in ("attachments", "recent_comments", "reactions", "bookmarked", "poll"):
|
||||
assert key in item
|
||||
|
||||
|
||||
def test_build_post_detail_shape(local_db):
|
||||
from devplacepy_services.database.compounds_page import build_post_detail
|
||||
|
||||
user = _make_user(local_db)
|
||||
post = _make_post(local_db, user)
|
||||
|
||||
result = build_post_detail(post["uid"], user=user)
|
||||
|
||||
assert result["post"]["uid"] == post["uid"]
|
||||
assert result["author"]["uid"] == user["uid"]
|
||||
assert isinstance(result["comments"], list)
|
||||
assert isinstance(result["attachments"], list)
|
||||
|
||||
|
||||
def test_build_post_detail_missing_post(local_db):
|
||||
from devplacepy_services.database.compounds_page import build_post_detail
|
||||
|
||||
result = build_post_detail("does-not-exist")
|
||||
assert result["post"] is None
|
||||
|
||||
|
||||
def test_build_profile_bundle_shape(local_db):
|
||||
from devplacepy_services.database.compounds_page import build_profile_bundle
|
||||
|
||||
user = _make_user(local_db)
|
||||
|
||||
result = build_profile_bundle(user["uid"])
|
||||
|
||||
assert result["user"]["uid"] == user["uid"]
|
||||
assert "followers" in result["follow_counts"]
|
||||
assert "following" in result["follow_counts"]
|
||||
|
||||
|
||||
def test_build_leaderboard_page_shape(local_db):
|
||||
from devplacepy_services.database.compounds_page import build_leaderboard_page
|
||||
|
||||
user = _make_user(local_db)
|
||||
|
||||
result = build_leaderboard_page(viewer_uid=user["uid"])
|
||||
|
||||
assert "leaderboard" in result
|
||||
assert isinstance(result["leaderboard"], list)
|
||||
|
||||
|
||||
def test_build_project_detail_shape(local_db):
|
||||
from devplacepy_services.database.compounds_page import build_project_detail
|
||||
|
||||
user = _make_user(local_db)
|
||||
project = _make_project(local_db, user)
|
||||
|
||||
result = build_project_detail(project["uid"])
|
||||
|
||||
assert result["project"]["uid"] == project["uid"]
|
||||
assert result["owner"]["uid"] == user["uid"]
|
||||
|
||||
|
||||
def test_build_gist_detail_shape(local_db):
|
||||
from devplacepy_services.database.compounds_page import build_gist_detail
|
||||
|
||||
user = _make_user(local_db)
|
||||
gist = _make_gist(local_db, user)
|
||||
|
||||
result = build_gist_detail(gist["uid"], user=user)
|
||||
|
||||
assert result["gist"]["uid"] == gist["uid"]
|
||||
assert result["author"]["uid"] == user["uid"]
|
||||
assert isinstance(result["comments"], list)
|
||||
|
||||
|
||||
# Section 5.3 rows registered as HTTP endpoints (devplacepy_services/database/routes.py)
|
||||
# but not yet called by any web route (routers/messages.py, notifications.py,
|
||||
# game/, admin/index.py all still do their own direct db_client calls) - these
|
||||
# four builders are unfinished stubs, tracked here so a real implementation
|
||||
# lands as a visible test change rather than silently.
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"fn_name,expected_keys",
|
||||
[
|
||||
("build_messages_page", {"threads", "unread", "partners"}),
|
||||
("build_notifications_page", {"notifications", "actors", "unread_count"}),
|
||||
("build_game_state", {"store", "leaderboard"}),
|
||||
("build_admin_dashboard", {"stats", "service_states"}),
|
||||
],
|
||||
)
|
||||
def test_stub_compounds_documented(local_db, fn_name, expected_keys):
|
||||
import devplacepy_services.database.compounds_page as compounds_page
|
||||
|
||||
fn = getattr(compounds_page, fn_name)
|
||||
result = fn() if fn_name != "build_admin_dashboard" else fn()
|
||||
assert set(result.keys()) == expected_keys, (
|
||||
f"{fn_name} shape changed - if this is a real implementation now, "
|
||||
"replace this stub-tracking test with a real assertion and update "
|
||||
"splitup.md Section 17.2"
|
||||
)
|
||||
@@ -0,0 +1,56 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from devplacepy_services.base.config import PROFILES
|
||||
|
||||
SERVICE_MODULES = (
|
||||
"devplacepy_services.database.main",
|
||||
"devplacepy_services.pubsub.main",
|
||||
"devplacepy_services.web.main",
|
||||
"devplacepy_services.gateway.main",
|
||||
"devplacepy_services.jobs.main",
|
||||
"devplacepy_services.devii.main",
|
||||
"devplacepy_services.bot.main",
|
||||
"devplacepy_services.backup.main",
|
||||
"devplacepy_services.containers.main",
|
||||
"devplacepy_services.telegram.main",
|
||||
"devplacepy_services.email.main",
|
||||
"devplacepy_services.news.main",
|
||||
"devplacepy_services.gitea.main",
|
||||
"devplacepy_services.audit.main",
|
||||
"devplacepy_services.xmlrpc.main",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("module_name", SERVICE_MODULES)
|
||||
def test_service_imports(module_name):
|
||||
env = os.environ.copy()
|
||||
env["DEVPLACE_DISABLE_SERVICES"] = "1"
|
||||
env["DEVPLACE_DATABASE_URL"] = "sqlite:///:memory:"
|
||||
env.pop("DEVPLACE_REMOTE_DB", None)
|
||||
env.pop("DEVPLACE_DB_SERVICE", None)
|
||||
script = (
|
||||
f"import importlib; m = importlib.import_module('{module_name}'); "
|
||||
"assert hasattr(m, 'app')"
|
||||
)
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", script],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr or result.stdout
|
||||
|
||||
|
||||
def test_port_profiles_complete():
|
||||
for profile in ("micro-dev", "micro-test", "micro-prod"):
|
||||
ports = PROFILES[profile]
|
||||
assert "web" in ports
|
||||
assert ports["web"] in (10500, 20500)
|
||||
Reference in New Issue
Block a user