feat: add messaging transport rollout
This commit is contained in:
@@ -1,112 +0,0 @@
|
||||
"""HTTP /v1 API tests via in-memory adapters (REQ-005/007/008).
|
||||
|
||||
No PostgreSQL needed: the app is wired with the in-memory slot repo + intent
|
||||
store, exercising the full request → admission → transition → response path.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from trex_core.adapters.api.app import create_app
|
||||
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore
|
||||
from trex_core.application.intents import IntentService
|
||||
from trex_core.domain.slot import SlotService, SlotState
|
||||
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
||||
|
||||
DAY = date(2026, 6, 3)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ctx():
|
||||
repo = InMemorySlotRepository()
|
||||
svc = SlotService(repo)
|
||||
intents = IntentService(svc, repo, InMemoryIntentStore())
|
||||
svc.preallocate_daily_slots(DAY, 3)
|
||||
app = create_app(
|
||||
slot_service=svc, slot_repo=repo, intent_service=intents, default_total_slots=3
|
||||
)
|
||||
return repo, svc, intents, TestClient(app)
|
||||
|
||||
|
||||
def _buy(client, intent_id, symbol="005930"):
|
||||
return client.post(
|
||||
"/v1/intents/buy",
|
||||
json={"intent_id": intent_id, "trading_date": DAY.isoformat(), "symbol": symbol, "qty": 10},
|
||||
)
|
||||
|
||||
|
||||
def test_healthz(ctx):
|
||||
_, _, _, client = ctx
|
||||
r = client.get("/v1/healthz")
|
||||
assert r.status_code == 200 and r.json()["status"] == "ok"
|
||||
|
||||
|
||||
def test_buy_intent_admits_and_transitions(ctx):
|
||||
repo, _, _, client = ctx
|
||||
r = _buy(client, "buy-1")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["status"] == "admitted" and body["slot_id"]
|
||||
slot = repo.fetch_slot(__import__("uuid").UUID(body["slot_id"]))
|
||||
assert slot.status is SlotState.PENDING_BUY and slot.symbol == "005930"
|
||||
|
||||
|
||||
def test_buy_intent_idempotent_resubmit(ctx):
|
||||
repo, _, _, client = ctx
|
||||
r1 = _buy(client, "buy-dup")
|
||||
r2 = _buy(client, "buy-dup") # same intent_id
|
||||
assert r1.json() == r2.json() # identical decision
|
||||
# only ONE slot moved out of idle
|
||||
pending = [s for s in repo.list_slots(DAY) if s.status is SlotState.PENDING_BUY]
|
||||
assert len(pending) == 1
|
||||
|
||||
|
||||
def test_buy_intent_rejected_when_no_idle(ctx):
|
||||
_, _, _, client = ctx
|
||||
for i in range(3):
|
||||
assert _buy(client, f"buy-fill-{i}").json()["status"] == "admitted"
|
||||
r = _buy(client, "buy-overflow")
|
||||
assert r.json()["status"] == "rejected" and r.json()["reason"] == "no_idle_slot"
|
||||
|
||||
|
||||
def test_sell_intent_requires_holding(ctx):
|
||||
repo, _, _, client = ctx
|
||||
# a freshly bought slot is pending_buy, not holding -> sell rejected
|
||||
slot_id = _buy(client, "buy-for-sell").json()["slot_id"]
|
||||
r = client.post("/v1/intents/sell", json={"intent_id": "sell-1", "slot_id": slot_id})
|
||||
assert r.json()["status"] == "rejected" and r.json()["reason"].startswith("slot_not_holding")
|
||||
|
||||
|
||||
def test_sell_intent_admits_from_holding(ctx):
|
||||
repo, svc, _, client = ctx
|
||||
import uuid
|
||||
|
||||
slot_id = uuid.UUID(_buy(client, "buy-h").json()["slot_id"])
|
||||
svc.transit_slot(slot_id, SlotState.PENDING_BUY, SlotState.HOLDING, reason="filled", actor="t")
|
||||
r = client.post("/v1/intents/sell", json={"intent_id": "sell-h", "slot_id": str(slot_id)})
|
||||
assert r.json()["status"] == "admitted"
|
||||
assert repo.fetch_slot(slot_id).status is SlotState.PENDING_SELL
|
||||
|
||||
|
||||
def test_get_intent_roundtrip(ctx):
|
||||
_, _, _, client = ctx
|
||||
_buy(client, "buy-q")
|
||||
r = client.get("/v1/intents/buy-q")
|
||||
assert r.status_code == 200 and r.json()["status"] == "admitted"
|
||||
assert client.get("/v1/intents/does-not-exist").status_code == 404
|
||||
|
||||
|
||||
def test_list_slots_and_capacity(ctx):
|
||||
_, _, _, client = ctx
|
||||
_buy(client, "buy-c")
|
||||
slots = client.get(
|
||||
"/v1/slots", params={"status": "pending_buy", "trading_date": DAY.isoformat()}
|
||||
).json()
|
||||
assert len(slots) == 1 and slots[0]["status"] == "pending_buy"
|
||||
cap = client.get(
|
||||
"/v1/capacity", params={"total_slots": 3, "trading_date": DAY.isoformat()}
|
||||
).json()
|
||||
assert cap["idle_count"] == 2 and cap["pending_count"] == 1 and cap["total_slots"] == 3
|
||||
@@ -0,0 +1,88 @@
|
||||
"""InMemoryTransport stub tests (REQ-022 AC-1 + REQ-024 degrade)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from trex_core.adapters.messaging.in_memory import InMemoryTransport
|
||||
from trex_core.adapters.messaging import topology
|
||||
from trex_core.application.messaging import (
|
||||
RpcRequest,
|
||||
RpcResponse,
|
||||
RpcTimeoutError,
|
||||
TransportHealth,
|
||||
TransportUnavailableError,
|
||||
)
|
||||
|
||||
|
||||
def test_publish_and_subscribe_routes_by_pattern():
|
||||
t = InMemoryTransport()
|
||||
received: list[tuple[str, dict]] = []
|
||||
stop = threading.Event()
|
||||
th = threading.Thread(
|
||||
target=t.consume_events_forever,
|
||||
args=("trex.events.strategy.A", topology.strategy_event_binding_pattern("A"),
|
||||
lambda rk, p: received.append((rk, p)), stop),
|
||||
daemon=True,
|
||||
)
|
||||
th.start()
|
||||
time.sleep(0.05)
|
||||
# Routed to A's subscriber
|
||||
t.publish_event(topology.event_routing_key("slot_filled_buy", "A", "005930"),
|
||||
{"slot": "1"})
|
||||
# NOT routed to A's subscriber (strategy B)
|
||||
t.publish_event(topology.event_routing_key("slot_filled_buy", "B", "005930"),
|
||||
{"slot": "2"})
|
||||
time.sleep(0.05)
|
||||
stop.set()
|
||||
th.join(timeout=1.0)
|
||||
assert len(received) == 1
|
||||
assert received[0][0] == "slot_filled_buy.A.005930"
|
||||
assert received[0][1] == {"slot": "1"}
|
||||
|
||||
|
||||
def test_rpc_call_dispatches_to_registered_handler():
|
||||
t = InMemoryTransport()
|
||||
|
||||
def handler(req: RpcRequest) -> RpcResponse:
|
||||
return RpcResponse(payload={"echo": req.payload, "cid": req.correlation_id})
|
||||
|
||||
stop = threading.Event()
|
||||
th = threading.Thread(
|
||||
target=t.consume_rpc_forever,
|
||||
args=("trex.intent.req", handler, stop),
|
||||
daemon=True,
|
||||
)
|
||||
th.start()
|
||||
time.sleep(0.05)
|
||||
resp = t.call_rpc("trex.intent.req", {"x": 1}, correlation_id="cid-1", timeout=0.5)
|
||||
assert resp["echo"] == {"x": 1}
|
||||
assert resp["cid"] == "cid-1"
|
||||
stop.set()
|
||||
th.join(timeout=1.0)
|
||||
|
||||
|
||||
def test_unavailable_transport_raises_on_publish_and_rpc():
|
||||
t = InMemoryTransport()
|
||||
t.set_available(False)
|
||||
assert t.health() is TransportHealth.DISCONNECTED
|
||||
with pytest.raises(TransportUnavailableError):
|
||||
t.publish_event("e.A.S", {"x": 1})
|
||||
with pytest.raises(TransportUnavailableError):
|
||||
t.call_rpc("trex.intent.req", {"x": 1}, correlation_id="cid", timeout=0.1)
|
||||
|
||||
|
||||
def test_rpc_without_registered_consumer_times_out():
|
||||
t = InMemoryTransport()
|
||||
with pytest.raises(RpcTimeoutError):
|
||||
t.call_rpc("trex.intent.req", {}, correlation_id="cid", timeout=0.05)
|
||||
|
||||
|
||||
def test_publish_event_with_no_subscriber_routes_to_dlq():
|
||||
t = InMemoryTransport()
|
||||
t.publish_event("slot_filled_buy.A.005930", {"slot": "1"})
|
||||
dlq = t.dlq_messages()
|
||||
assert len(dlq) == 1
|
||||
assert dlq[0][0] == "slot_filled_buy.A.005930"
|
||||
@@ -0,0 +1,127 @@
|
||||
"""End-to-end intent RPC against RabbitMQ + intent_log dedup (REQ-023 AC-1/AC-3).
|
||||
|
||||
Skipped without TREX_RABBITMQ_TEST_URL. Wires the real RabbitMQTransport to
|
||||
the application-layer intent handler and proves that:
|
||||
- publishing the same correlation_id twice triggers slot transition only ONCE
|
||||
- the durable RPC queue is declared with the DLQ pointer (AC-3 topology check)
|
||||
- intent_log persists the dedup decision
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
from trex_core.adapters.messaging import topology
|
||||
from trex_core.adapters.messaging.rabbitmq import RabbitMQTransport
|
||||
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore
|
||||
from trex_core.application.intents import IntentService
|
||||
from trex_core.application.messaging_handlers import make_intent_rpc_handler
|
||||
from trex_core.domain.slot import SlotService, SlotState
|
||||
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
||||
|
||||
DAY = date(2026, 6, 5)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mq_url() -> str:
|
||||
url = os.environ.get("TREX_RABBITMQ_TEST_URL")
|
||||
if not url:
|
||||
pytest.skip("TREX_RABBITMQ_TEST_URL not set")
|
||||
return url
|
||||
|
||||
|
||||
def _drain(url: str, queue: str) -> None:
|
||||
import pika
|
||||
conn = pika.BlockingConnection(pika.URLParameters(url))
|
||||
try:
|
||||
ch = conn.channel()
|
||||
try:
|
||||
ch.queue_delete(queue=queue)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_intent_admission_rpc_dedups_duplicate_correlation_id_over_rabbitmq(mq_url):
|
||||
"""REQ-023 AC-1: same correlation_id processed twice ⇒ slot transitions ONCE."""
|
||||
repo = InMemorySlotRepository()
|
||||
svc = SlotService(repo)
|
||||
intents = IntentService(svc, repo, InMemoryIntentStore())
|
||||
svc.preallocate_daily_slots(DAY, 3)
|
||||
handler = make_intent_rpc_handler(intents)
|
||||
|
||||
queue = f"trex.test.intent.{uuid.uuid4().hex[:8]}"
|
||||
server = RabbitMQTransport(mq_url, timeout=5.0)
|
||||
client = RabbitMQTransport(mq_url, timeout=5.0)
|
||||
try:
|
||||
# Pre-declare with DLQ pointer (mirrors production declare_topology shape)
|
||||
server._ensure_connected()
|
||||
server._channel.queue_declare(
|
||||
queue, durable=True,
|
||||
arguments={"x-dead-letter-exchange": topology.EVENTS_DLQ_EXCHANGE},
|
||||
)
|
||||
|
||||
stop = threading.Event()
|
||||
th = threading.Thread(
|
||||
target=server.consume_rpc_forever, args=(queue, handler, stop), daemon=True,
|
||||
)
|
||||
th.start()
|
||||
time.sleep(0.2)
|
||||
|
||||
cid = "buy-dup-rmq"
|
||||
payload = {
|
||||
"intent_type": "buy", "intent_id": cid,
|
||||
"trading_date": DAY.isoformat(), "symbol": "005930", "qty": 10,
|
||||
}
|
||||
r1 = client.call_rpc(queue, payload, correlation_id=cid, timeout=3.0)
|
||||
r2 = client.call_rpc(queue, payload, correlation_id=cid, timeout=3.0)
|
||||
assert r1 == r2
|
||||
assert r1["status"] == "admitted"
|
||||
|
||||
pending = [s for s in repo.list_slots(DAY) if s.status is SlotState.PENDING_BUY]
|
||||
assert len(pending) == 1, "intent_log dedup must prevent double slot transition"
|
||||
|
||||
stop.set()
|
||||
th.join(timeout=2.0)
|
||||
finally:
|
||||
server.close()
|
||||
client.close()
|
||||
_drain(mq_url, queue)
|
||||
|
||||
|
||||
def test_intent_queue_topology_uses_dlq_pointer(mq_url):
|
||||
"""REQ-023 AC-3 / REQ-022 AC-2 topology assertion."""
|
||||
import pika
|
||||
t = RabbitMQTransport(mq_url, timeout=5.0)
|
||||
try:
|
||||
t.declare_topology()
|
||||
# Re-declare with the SAME args is no-op; declaring with DIFFERENT args
|
||||
# would raise PRECONDITION_FAILED. Use that as a topology assertion.
|
||||
conn = pika.BlockingConnection(pika.URLParameters(mq_url))
|
||||
try:
|
||||
ch = conn.channel()
|
||||
# Same args → ok
|
||||
ch.queue_declare(
|
||||
topology.INTENT_REQ_QUEUE, durable=True,
|
||||
arguments={"x-dead-letter-exchange": topology.EVENTS_DLQ_EXCHANGE},
|
||||
)
|
||||
ch.close()
|
||||
# Different args → must raise
|
||||
ch2 = conn.channel()
|
||||
with pytest.raises(pika.exceptions.ChannelClosedByBroker):
|
||||
ch2.queue_declare(
|
||||
topology.INTENT_REQ_QUEUE, durable=False,
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
finally:
|
||||
t.close()
|
||||
@@ -0,0 +1,171 @@
|
||||
"""RabbitMQTransport integration tests (REQ-022 AC-2, REQ-023, REQ-024).
|
||||
|
||||
Skipped without TREX_RABBITMQ_TEST_URL. Each test uses a unique uuid in
|
||||
queue/routing keys to avoid colliding with other tests or runs.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from trex_core.adapters.messaging import topology
|
||||
from trex_core.adapters.messaging.rabbitmq import RabbitMQTransport
|
||||
from trex_core.application.messaging import (
|
||||
RpcRequest,
|
||||
RpcResponse,
|
||||
RpcTimeoutError,
|
||||
TransportHealth,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mq_url() -> str:
|
||||
url = os.environ.get("TREX_RABBITMQ_TEST_URL")
|
||||
if not url:
|
||||
pytest.skip("TREX_RABBITMQ_TEST_URL not set; skipping RabbitMQ integration test")
|
||||
return url
|
||||
|
||||
|
||||
def _drain_queue(url: str, queue: str) -> None:
|
||||
import pika
|
||||
conn = pika.BlockingConnection(pika.URLParameters(url))
|
||||
try:
|
||||
ch = conn.channel()
|
||||
try:
|
||||
ch.queue_delete(queue=queue)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_declare_topology_creates_core_entities(mq_url):
|
||||
t = RabbitMQTransport(mq_url, timeout=5.0)
|
||||
try:
|
||||
t.declare_topology()
|
||||
# If declare_topology succeeded, exchanges/queues now exist. Re-declare
|
||||
# is idempotent (RabbitMQ no-op on existing matching definitions).
|
||||
t.declare_topology()
|
||||
assert t.health() is TransportHealth.CONNECTED
|
||||
finally:
|
||||
t.close()
|
||||
|
||||
|
||||
def test_rpc_roundtrip(mq_url):
|
||||
queue = f"trex.test.rpc.{uuid.uuid4().hex[:8]}"
|
||||
server = RabbitMQTransport(mq_url, timeout=5.0)
|
||||
client = RabbitMQTransport(mq_url, timeout=5.0)
|
||||
try:
|
||||
# Pre-declare RPC queue (server-side will redeclare too).
|
||||
ch = server._channel
|
||||
if server._conn is None:
|
||||
server._connect()
|
||||
ch = server._channel
|
||||
ch.queue_declare(queue, durable=True,
|
||||
arguments={"x-dead-letter-exchange": topology.EVENTS_DLQ_EXCHANGE})
|
||||
|
||||
def handler(req: RpcRequest) -> RpcResponse:
|
||||
return RpcResponse(payload={"echo": req.payload, "cid": req.correlation_id})
|
||||
|
||||
stop = threading.Event()
|
||||
th = threading.Thread(
|
||||
target=server.consume_rpc_forever,
|
||||
args=(queue, handler, stop),
|
||||
daemon=True,
|
||||
)
|
||||
th.start()
|
||||
time.sleep(0.2)
|
||||
resp = client.call_rpc(queue, {"hello": "world"}, correlation_id="cid-1", timeout=3.0)
|
||||
assert resp["echo"] == {"hello": "world"}
|
||||
assert resp["cid"] == "cid-1"
|
||||
stop.set()
|
||||
th.join(timeout=2.0)
|
||||
finally:
|
||||
server.close()
|
||||
client.close()
|
||||
_drain_queue(mq_url, queue)
|
||||
|
||||
|
||||
def test_event_publish_and_per_strategy_consume(mq_url):
|
||||
suffix = uuid.uuid4().hex[:8]
|
||||
strategy_a = f"A-{suffix}"
|
||||
strategy_b = f"B-{suffix}"
|
||||
queue_a = topology.strategy_event_queue_name(strategy_a)
|
||||
queue_b = topology.strategy_event_queue_name(strategy_b)
|
||||
|
||||
publisher = RabbitMQTransport(mq_url, timeout=5.0)
|
||||
consumer_a = RabbitMQTransport(mq_url, timeout=5.0)
|
||||
consumer_b = RabbitMQTransport(mq_url, timeout=5.0)
|
||||
received_a: list[tuple[str, dict]] = []
|
||||
received_b: list[tuple[str, dict]] = []
|
||||
stop = threading.Event()
|
||||
try:
|
||||
publisher.declare_topology()
|
||||
# Pre-declare per-strategy queues so the publisher's message is not
|
||||
# discarded before the consumers start.
|
||||
publisher.declare_strategy_queue(queue_a, topology.strategy_event_binding_pattern(strategy_a))
|
||||
publisher.declare_strategy_queue(queue_b, topology.strategy_event_binding_pattern(strategy_b))
|
||||
|
||||
th_a = threading.Thread(
|
||||
target=consumer_a.consume_events_forever,
|
||||
args=(queue_a, topology.strategy_event_binding_pattern(strategy_a),
|
||||
lambda rk, p: received_a.append((rk, p)), stop),
|
||||
daemon=True,
|
||||
)
|
||||
th_b = threading.Thread(
|
||||
target=consumer_b.consume_events_forever,
|
||||
args=(queue_b, topology.strategy_event_binding_pattern(strategy_b),
|
||||
lambda rk, p: received_b.append((rk, p)), stop),
|
||||
daemon=True,
|
||||
)
|
||||
th_a.start()
|
||||
th_b.start()
|
||||
time.sleep(0.3)
|
||||
|
||||
rk_a = topology.event_routing_key("slot_filled_buy", strategy_a, "005930")
|
||||
rk_b = topology.event_routing_key("slot_filled_buy", strategy_b, "005930")
|
||||
publisher.publish_event(rk_a, {"slot": "1", "for": "A"})
|
||||
publisher.publish_event(rk_b, {"slot": "2", "for": "B"})
|
||||
|
||||
deadline = time.monotonic() + 3.0
|
||||
while time.monotonic() < deadline and (not received_a or not received_b):
|
||||
time.sleep(0.05)
|
||||
|
||||
assert any(rk == rk_a for rk, _ in received_a)
|
||||
assert all(rk != rk_b for rk, _ in received_a), "A queue must not receive B events"
|
||||
assert any(rk == rk_b for rk, _ in received_b)
|
||||
assert all(rk != rk_a for rk, _ in received_b), "B queue must not receive A events"
|
||||
finally:
|
||||
stop.set()
|
||||
time.sleep(0.2)
|
||||
publisher.close()
|
||||
consumer_a.close()
|
||||
consumer_b.close()
|
||||
_drain_queue(mq_url, queue_a)
|
||||
_drain_queue(mq_url, queue_b)
|
||||
|
||||
|
||||
def test_call_rpc_timeout(mq_url):
|
||||
queue = f"trex.test.rpc.timeout.{uuid.uuid4().hex[:8]}"
|
||||
client = RabbitMQTransport(mq_url, timeout=5.0)
|
||||
try:
|
||||
# Declare a queue with no consumer; RPC must time out.
|
||||
client._ensure_connected()
|
||||
client._channel.queue_declare(queue, durable=True)
|
||||
with pytest.raises(RpcTimeoutError):
|
||||
client.call_rpc(queue, {"x": 1}, correlation_id="cid-timeout", timeout=0.5)
|
||||
finally:
|
||||
client.close()
|
||||
_drain_queue(mq_url, queue)
|
||||
|
||||
|
||||
def test_close_marks_disconnected(mq_url):
|
||||
t = RabbitMQTransport(mq_url, timeout=5.0)
|
||||
t.declare_topology()
|
||||
assert t.health() is TransportHealth.CONNECTED
|
||||
t.close()
|
||||
assert t.health() is TransportHealth.DISCONNECTED
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Topology constants + routing key builders (REQ-021)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from trex_core.adapters.messaging import topology
|
||||
|
||||
|
||||
def test_topology_constants():
|
||||
assert topology.SNAPSHOT_REQ_QUEUE == "trex.snapshot.req"
|
||||
assert topology.INTENT_REQ_QUEUE == "trex.intent.req"
|
||||
assert topology.EVENTS_EXCHANGE == "trex.events"
|
||||
assert topology.EVENTS_DLQ_EXCHANGE == "trex.events.dlq"
|
||||
|
||||
|
||||
def test_event_routing_key_format():
|
||||
assert topology.event_routing_key("slot_filled_buy", "A", "005930") == \
|
||||
"slot_filled_buy.A.005930"
|
||||
# dots/wildcards in inputs are sanitized to underscores
|
||||
assert topology.event_routing_key("e.x", "y*z", "a#b") == "e_x.y_z.a_b"
|
||||
# None / empty strategy → "_"
|
||||
assert topology.event_routing_key("e", None, "S") == "e._.S"
|
||||
|
||||
|
||||
def test_strategy_queue_name_and_binding_pattern():
|
||||
assert topology.strategy_event_queue_name("A") == "trex.events.strategy.A"
|
||||
assert topology.strategy_event_binding_pattern("A") == "*.A.#"
|
||||
|
||||
|
||||
def test_strategy_queue_args_have_ttl_and_max_length():
|
||||
args = topology.STRATEGY_QUEUE_ARGS
|
||||
assert args["x-message-ttl"] > 0
|
||||
assert args["x-max-length"] > 0
|
||||
assert args["x-overflow"] == "drop-head"
|
||||
assert args["x-dead-letter-exchange"] == topology.EVENTS_DLQ_EXCHANGE
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Slot transition event emission tests (REQ-021 + REQ-023 + REQ-018 hook)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from datetime import date
|
||||
|
||||
from trex_core.adapters.broker.paper import PaperBroker
|
||||
from trex_core.adapters.ledger.in_memory import InMemoryLedger
|
||||
from trex_core.adapters.messaging import topology
|
||||
from trex_core.adapters.messaging.in_memory import InMemoryTransport
|
||||
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore
|
||||
from trex_core.application.execution import ExecutionService
|
||||
from trex_core.application.intents import BuyIntent, IntentService
|
||||
from trex_core.application.ledger_outbox import InMemoryLedgerOutbox, LedgerWriter
|
||||
from trex_core.application.messaging_handlers import slot_transition_event_payload
|
||||
from trex_core.domain.slot import SlotService, SlotState
|
||||
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
||||
|
||||
DAY = date(2026, 6, 5)
|
||||
|
||||
|
||||
def _wire(transport):
|
||||
repo = InMemorySlotRepository()
|
||||
svc = SlotService(repo)
|
||||
intents = IntentService(svc, repo, InMemoryIntentStore())
|
||||
outbox = InMemoryLedgerOutbox()
|
||||
ledger = InMemoryLedger()
|
||||
writer = LedgerWriter(outbox, ledger)
|
||||
svc.preallocate_daily_slots(DAY, 3)
|
||||
|
||||
def emitter(event_type: str, slot) -> None:
|
||||
rk = topology.event_routing_key(event_type, slot.strategy_id or "_", slot.symbol or "_")
|
||||
payload = slot_transition_event_payload(slot, event_type=event_type)
|
||||
try:
|
||||
transport.publish_event(rk, payload)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
execution = ExecutionService(svc, repo, PaperBroker(fill_after_polls=0),
|
||||
ledger_outbox=outbox, event_emitter=emitter)
|
||||
return repo, svc, intents, execution, writer
|
||||
|
||||
|
||||
def test_fill_emits_slot_filled_event_with_routing_key_and_state():
|
||||
transport = InMemoryTransport()
|
||||
received: list[tuple[str, dict]] = []
|
||||
stop = threading.Event()
|
||||
th = threading.Thread(
|
||||
target=transport.consume_events_forever,
|
||||
args=("trex.events.strategy.A", topology.strategy_event_binding_pattern("A"),
|
||||
lambda rk, p: received.append((rk, p)), stop),
|
||||
daemon=True,
|
||||
)
|
||||
th.start()
|
||||
time.sleep(0.05)
|
||||
|
||||
repo, _, intents, execution, _ = _wire(transport)
|
||||
intents.submit_buy(BuyIntent(
|
||||
intent_id="b1", trading_date=DAY, symbol="005930", qty=10,
|
||||
strategy_id="A", portfolio_id="PF-1",
|
||||
))
|
||||
execution.run_once()
|
||||
time.sleep(0.05)
|
||||
stop.set()
|
||||
th.join(timeout=1.0)
|
||||
|
||||
assert len(received) == 1
|
||||
rk, payload = received[0]
|
||||
assert rk == "slot_filled_buy.A.005930"
|
||||
# payload carries current state + monotonic sequence (REQ-023 AC-5)
|
||||
assert payload["event_type"] == "slot_filled_buy"
|
||||
assert payload["status"] == SlotState.HOLDING.value
|
||||
assert payload["strategy_id"] == "A"
|
||||
assert payload["symbol"] == "005930"
|
||||
assert "sequence_at" in payload # last-wins ordering token
|
||||
|
||||
|
||||
def test_event_for_strategy_a_does_not_reach_strategy_b_queue():
|
||||
"""REQ-021 AC-4: per-strategy routing key binding filters at the broker."""
|
||||
transport = InMemoryTransport()
|
||||
received_a: list[tuple[str, dict]] = []
|
||||
received_b: list[tuple[str, dict]] = []
|
||||
stop = threading.Event()
|
||||
th_a = threading.Thread(
|
||||
target=transport.consume_events_forever,
|
||||
args=("q.A", topology.strategy_event_binding_pattern("A"),
|
||||
lambda rk, p: received_a.append((rk, p)), stop),
|
||||
daemon=True,
|
||||
)
|
||||
th_b = threading.Thread(
|
||||
target=transport.consume_events_forever,
|
||||
args=("q.B", topology.strategy_event_binding_pattern("B"),
|
||||
lambda rk, p: received_b.append((rk, p)), stop),
|
||||
daemon=True,
|
||||
)
|
||||
th_a.start()
|
||||
th_b.start()
|
||||
time.sleep(0.05)
|
||||
|
||||
_, _, intents, execution, _ = _wire(transport)
|
||||
intents.submit_buy(BuyIntent(
|
||||
intent_id="b1", trading_date=DAY, symbol="005930", qty=10,
|
||||
strategy_id="A", portfolio_id="PF-1",
|
||||
))
|
||||
execution.run_once()
|
||||
time.sleep(0.05)
|
||||
stop.set()
|
||||
th_a.join(timeout=1.0)
|
||||
th_b.join(timeout=1.0)
|
||||
|
||||
assert any(rk == "slot_filled_buy.A.005930" for rk, _ in received_a)
|
||||
assert received_b == []
|
||||
|
||||
|
||||
def test_publish_failure_does_not_break_execution_loop():
|
||||
"""Publish errors must NOT roll back slot transitions or ledger enqueue."""
|
||||
transport = InMemoryTransport()
|
||||
transport.set_available(False) # all publishes will raise TransportUnavailableError
|
||||
|
||||
repo, _, intents, execution, _ = _wire(transport)
|
||||
intents.submit_buy(BuyIntent(
|
||||
intent_id="b1", trading_date=DAY, symbol="005930", qty=10,
|
||||
strategy_id="A", portfolio_id="PF-1",
|
||||
))
|
||||
r = execution.run_once()
|
||||
# Slot still transitioned; ledger still enqueued.
|
||||
assert r.filled == 1
|
||||
assert r.ledger_enqueued == 1
|
||||
pending = [s for s in repo.list_slots(DAY) if s.status is SlotState.HOLDING]
|
||||
assert len(pending) == 1
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Intent admission RPC handler + dedup tests (REQ-021/022/023, dcy-006).
|
||||
|
||||
The handler calls IntentService.submit_*, so dedup uses the IntentStore
|
||||
(intent_log) gate (REQ-007). Two messages with the same correlation_id
|
||||
trigger exactly ONE slot transition (REQ-023 AC-1).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
|
||||
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore
|
||||
from trex_core.application.intents import IntentService
|
||||
from trex_core.application.messaging import RpcRequest
|
||||
from trex_core.application.messaging_handlers import make_intent_rpc_handler
|
||||
from trex_core.domain.slot import SlotService, SlotState
|
||||
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
||||
|
||||
DAY = date(2026, 6, 5)
|
||||
|
||||
|
||||
def _wire():
|
||||
repo = InMemorySlotRepository()
|
||||
svc = SlotService(repo)
|
||||
intents = IntentService(svc, repo, InMemoryIntentStore())
|
||||
svc.preallocate_daily_slots(DAY, 3)
|
||||
return repo, svc, intents
|
||||
|
||||
|
||||
def _buy_req(intent_id: str, symbol: str = "005930") -> RpcRequest:
|
||||
return RpcRequest(
|
||||
payload={
|
||||
"intent_type": "buy",
|
||||
"intent_id": intent_id,
|
||||
"trading_date": DAY.isoformat(),
|
||||
"symbol": symbol,
|
||||
"qty": 10,
|
||||
},
|
||||
correlation_id=intent_id,
|
||||
)
|
||||
|
||||
|
||||
def test_buy_admission_returns_admitted_and_transitions_slot():
|
||||
repo, _, intents = _wire()
|
||||
handler = make_intent_rpc_handler(intents)
|
||||
resp = handler(_buy_req("buy-1"))
|
||||
assert resp.payload["status"] == "admitted"
|
||||
assert resp.payload["slot_id"]
|
||||
from uuid import UUID
|
||||
slot = repo.fetch_slot(UUID(resp.payload["slot_id"]))
|
||||
assert slot.status is SlotState.PENDING_BUY and slot.symbol == "005930"
|
||||
|
||||
|
||||
def test_duplicate_correlation_id_dedups_via_intent_log():
|
||||
"""REQ-023 AC-1: same intent_id ⇒ slot transition exactly once."""
|
||||
repo, _, intents = _wire()
|
||||
handler = make_intent_rpc_handler(intents)
|
||||
r1 = handler(_buy_req("buy-dup"))
|
||||
r2 = handler(_buy_req("buy-dup"))
|
||||
assert r1.payload == r2.payload
|
||||
pending = [s for s in repo.list_slots(DAY) if s.status is SlotState.PENDING_BUY]
|
||||
assert len(pending) == 1
|
||||
|
||||
|
||||
def test_buy_rejected_when_no_idle_slot():
|
||||
repo, _, intents = _wire()
|
||||
handler = make_intent_rpc_handler(intents)
|
||||
for i in range(3):
|
||||
assert handler(_buy_req(f"buy-{i}")).payload["status"] == "admitted"
|
||||
resp = handler(_buy_req("overflow"))
|
||||
assert resp.payload["status"] == "rejected"
|
||||
assert resp.payload["reason"] == "no_idle_slot"
|
||||
|
||||
|
||||
def test_sell_admission_from_holding():
|
||||
repo, svc, intents = _wire()
|
||||
handler = make_intent_rpc_handler(intents)
|
||||
sid = handler(_buy_req("buy-h")).payload["slot_id"]
|
||||
from uuid import UUID
|
||||
svc.transit_slot(UUID(sid), SlotState.PENDING_BUY, SlotState.HOLDING,
|
||||
reason="filled", actor="t")
|
||||
resp = handler(RpcRequest(
|
||||
payload={"intent_type": "sell", "intent_id": "sell-h", "slot_id": sid},
|
||||
correlation_id="sell-h",
|
||||
))
|
||||
assert resp.payload["status"] == "admitted"
|
||||
assert repo.fetch_slot(UUID(sid)).status is SlotState.PENDING_SELL
|
||||
|
||||
|
||||
def test_bad_payload_returns_rejected_not_exception():
|
||||
"""Handler must not raise — bad input ⇒ structured rejection."""
|
||||
_, _, intents = _wire()
|
||||
handler = make_intent_rpc_handler(intents)
|
||||
# missing slot_id for sell
|
||||
resp = handler(RpcRequest(
|
||||
payload={"intent_type": "sell", "intent_id": "bad"},
|
||||
correlation_id="bad",
|
||||
))
|
||||
assert resp.payload["status"] == "rejected"
|
||||
assert resp.payload["reason"].startswith("bad_request:")
|
||||
|
||||
|
||||
def test_unknown_intent_type_rejected():
|
||||
_, _, intents = _wire()
|
||||
handler = make_intent_rpc_handler(intents)
|
||||
resp = handler(RpcRequest(
|
||||
payload={"intent_type": "ping", "intent_id": "x"},
|
||||
correlation_id="x",
|
||||
))
|
||||
assert resp.payload["status"] == "rejected"
|
||||
assert "unknown_intent_type" in resp.payload["reason"]
|
||||
|
||||
|
||||
def test_correlation_id_is_used_as_intent_id_when_missing_in_payload():
|
||||
"""The transport's correlation_id is the canonical idempotency key."""
|
||||
repo, _, intents = _wire()
|
||||
handler = make_intent_rpc_handler(intents)
|
||||
req = RpcRequest(
|
||||
payload={
|
||||
"intent_type": "buy",
|
||||
"trading_date": DAY.isoformat(),
|
||||
"symbol": "005930",
|
||||
"qty": 5,
|
||||
},
|
||||
correlation_id="cid-only",
|
||||
)
|
||||
r1 = handler(req)
|
||||
r2 = handler(req)
|
||||
assert r1.payload["status"] == "admitted"
|
||||
assert r1.payload == r2.payload
|
||||
pending = [s for s in repo.list_slots(DAY) if s.status is SlotState.PENDING_BUY]
|
||||
assert len(pending) == 1
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Snapshot RPC handler tests (REQ-021 AC-2, BAT-007 stub-shape)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore
|
||||
from trex_core.application.intents import BuyIntent, IntentService
|
||||
from trex_core.application.messaging import RpcRequest
|
||||
from trex_core.application.messaging_handlers import make_snapshot_rpc_handler
|
||||
from trex_core.domain.slot import SlotService
|
||||
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
||||
|
||||
DAY = date(2026, 6, 5)
|
||||
|
||||
|
||||
def _wire():
|
||||
repo = InMemorySlotRepository()
|
||||
svc = SlotService(repo)
|
||||
intents = IntentService(svc, repo, InMemoryIntentStore())
|
||||
svc.preallocate_daily_slots(DAY, 3)
|
||||
return repo, svc, intents
|
||||
|
||||
|
||||
def _req(view="all", strategy_id=None):
|
||||
return RpcRequest(
|
||||
payload={
|
||||
"view": view,
|
||||
"strategy_id": strategy_id,
|
||||
"trading_date": DAY.isoformat(),
|
||||
},
|
||||
correlation_id="snap-cid",
|
||||
)
|
||||
|
||||
|
||||
def test_snapshot_returns_all_slots_and_capacity():
|
||||
repo, svc, intents = _wire()
|
||||
intents.submit_buy(BuyIntent(
|
||||
intent_id="b1", trading_date=DAY, symbol="005930", qty=10,
|
||||
strategy_id="A",
|
||||
))
|
||||
handler = make_snapshot_rpc_handler(repo, svc)
|
||||
resp = handler(_req())
|
||||
assert resp.payload["trading_date"] == DAY.isoformat()
|
||||
assert resp.payload["view"] == "all"
|
||||
assert len(resp.payload["slots"]) == 3 # all 3 slots returned
|
||||
cap = resp.payload["capacity"]
|
||||
assert cap["pending_count"] == 1 and cap["idle_count"] == 2
|
||||
|
||||
|
||||
def test_snapshot_open_view_filters_to_active_slots():
|
||||
repo, svc, intents = _wire()
|
||||
intents.submit_buy(BuyIntent(
|
||||
intent_id="b1", trading_date=DAY, symbol="005930", qty=10, strategy_id="A",
|
||||
))
|
||||
handler = make_snapshot_rpc_handler(repo, svc)
|
||||
resp = handler(_req(view="open"))
|
||||
statuses = {s["status"] for s in resp.payload["slots"]}
|
||||
assert "idle" not in statuses
|
||||
assert statuses == {"pending_buy"}
|
||||
|
||||
|
||||
def test_snapshot_strategy_id_filter():
|
||||
repo, svc, intents = _wire()
|
||||
intents.submit_buy(BuyIntent(
|
||||
intent_id="b1", trading_date=DAY, symbol="005930", qty=10, strategy_id="A",
|
||||
))
|
||||
intents.submit_buy(BuyIntent(
|
||||
intent_id="b2", trading_date=DAY, symbol="000660", qty=5, strategy_id="B",
|
||||
))
|
||||
handler = make_snapshot_rpc_handler(repo, svc)
|
||||
resp = handler(_req(view="open", strategy_id="A"))
|
||||
assert len(resp.payload["slots"]) == 1
|
||||
assert resp.payload["slots"][0]["strategy_id"] == "A"
|
||||
assert resp.payload["slots"][0]["symbol"] == "005930"
|
||||
+7
-2
@@ -1,7 +1,12 @@
|
||||
"""Shared test fixtures.
|
||||
|
||||
PostgreSQL integration tests are gated on TREX_POSTGRESQL_TEST_DSN; when it
|
||||
is unset they are skipped (unit/API tests still run via in-memory adapters).
|
||||
Infrastructure integration tests are gated on env vars:
|
||||
- TREX_POSTGRESQL_TEST_DSN : PostgreSQL repository/outbox/migration tests
|
||||
- TREX_RABBITMQ_TEST_URL : RabbitMQ transport + intent RPC E2E tests
|
||||
- TREX_KIS_BROKER_* : KIS sandbox round-trip test
|
||||
|
||||
When a gate is unset the corresponding tests are skipped; unit tests
|
||||
(in-memory adapters, stub transport) always run.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -15,9 +15,11 @@ _FORBIDDEN_PREFIXES = (
|
||||
"httpx",
|
||||
"fastapi",
|
||||
"uvicorn",
|
||||
"starlette",
|
||||
"psycopg",
|
||||
"redis",
|
||||
"trex_core.adapters", # any adapter (broker, ledger, persistence, api)
|
||||
"pika", # BAT-007: AMQP must not leak into domain (REQ-022 AC-5)
|
||||
"trex_core.adapters", # any adapter (broker, ledger, messaging, persistence)
|
||||
"trex_core.runtime", # daemon / config
|
||||
"trex_core.application", # ports/services live one layer up
|
||||
)
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"""HTTP表면 0 회귀 가드 (REQ-025).
|
||||
|
||||
본 테스트는 데몬 import 그래프에 fastapi/uvicorn/starlette 가 0건임을
|
||||
정적으로 가드한다. 또한 pyproject.toml 의 runtime 의존성에서 이들이
|
||||
제거되어 있는지 확인한다.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def test_daemon_module_has_no_fastapi_or_uvicorn_import():
|
||||
# Importing the daemon must not pull in FastAPI/uvicorn/starlette anywhere
|
||||
# in the transitive trex_core graph.
|
||||
for forbidden in ("fastapi", "uvicorn", "starlette"):
|
||||
sys.modules.pop(forbidden, None)
|
||||
importlib.invalidate_caches()
|
||||
importlib.import_module("trex_core.runtime.daemon")
|
||||
# Walk all loaded trex_core.* modules and assert none triggered a forbidden import.
|
||||
leaked = [name for name in ("fastapi", "uvicorn", "starlette") if name in sys.modules]
|
||||
assert leaked == [], f"forbidden HTTP framework imports leaked: {leaked}"
|
||||
|
||||
|
||||
def test_adapters_api_tree_does_not_exist():
|
||||
assert not (_REPO_ROOT / "trex_core" / "adapters" / "api").exists(), (
|
||||
"trex_core/adapters/api must be removed (REQ-025 AC-1)"
|
||||
)
|
||||
|
||||
|
||||
def test_no_fastapi_or_uvicorn_in_trex_core_source():
|
||||
"""grep all .py under trex_core/ — must be 0 matches for fastapi/uvicorn."""
|
||||
pattern_files: list[Path] = []
|
||||
for py in (_REPO_ROOT / "trex_core").rglob("*.py"):
|
||||
text = py.read_text()
|
||||
if any(token in text for token in ("import fastapi", "from fastapi",
|
||||
"import uvicorn", "from uvicorn",
|
||||
"import starlette", "from starlette")):
|
||||
pattern_files.append(py.relative_to(_REPO_ROOT))
|
||||
assert pattern_files == [], f"fastapi/uvicorn imports leaked into: {pattern_files}"
|
||||
|
||||
|
||||
def test_pyproject_dependencies_have_no_fastapi_or_uvicorn():
|
||||
pyproject = (_REPO_ROOT / "pyproject.toml").read_text()
|
||||
# naive substring check is sufficient — we're guarding a removal, not parsing
|
||||
assert "fastapi" not in pyproject, "fastapi must be removed from pyproject.toml"
|
||||
assert "uvicorn" not in pyproject, "uvicorn must be removed from pyproject.toml"
|
||||
|
||||
|
||||
def test_config_env_no_longer_references_api_host_or_port():
|
||||
"""DaemonConfig must not read TREX_API_HOST/PORT (REQ-025 AC-3)."""
|
||||
cfg_src = (_REPO_ROOT / "trex_core" / "runtime" / "config.py").read_text()
|
||||
assert "TREX_API_HOST" not in cfg_src
|
||||
assert "TREX_API_PORT" not in cfg_src
|
||||
Reference in New Issue
Block a user