feat: add messaging transport rollout

This commit is contained in:
2026-06-06 00:02:52 +09:00
parent 4b91343ee8
commit 6c7a1cf6f6
383 changed files with 99045 additions and 487 deletions
-112
View File
@@ -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
+33
View File
@@ -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
View File
@@ -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
+3 -1
View File
@@ -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