cyberhybridhub/server/test/integration/trade_actuator_test.dart

324 lines
10 KiB
Dart

@Tags(['integration', 'postgres'])
library;
import 'dart:convert';
import 'package:cyberhybridhub_server/alpaca/alpaca_env.dart';
import 'package:cyberhybridhub_server/alpaca/alpaca_trading_client.dart';
import 'package:cyberhybridhub_server/trading/guardrails.dart';
import 'package:cyberhybridhub_server/trading/trade_actuator.dart';
import 'package:cyberhybridhub_server/trading/trade_orders_db.dart';
import 'package:http/http.dart' as http;
import 'package:test/test.dart';
import '../helpers/fixture_loader.dart';
import '../helpers/mock_http_client.dart';
import '../helpers/test_db.dart';
void main() {
TestDb? testDb;
setUpAll(() async {
testDb = await TestDb.open();
});
tearDown(() async {
if (testDb != null) {
await testDb!.truncateTradingTables();
}
});
tearDownAll(() async {
await testDb?.close();
});
Future<void> _seedConfig(String uid) async {
await testDb!.seedUser(uid);
await testDb!.tradingConfigDb.upsertUserConfig(
firebaseUid: uid,
templateName: 'default_paper_watchlist',
enabled: true,
);
}
Future<void> _stagePending(
String uid, {
required String clientOrderId,
required String questionId,
required String ruleId,
String symbol = 'SPY',
String side = 'buy',
num notional = 10,
}) async {
await testDb!.userTradingStateDb.addPendingOrder(
firebaseUid: uid,
order: <String, dynamic>{
'rule_id': ruleId,
'question_id': questionId,
'symbol': symbol,
'side': side,
'order_type': 'market',
'notional_usd': notional,
'client_order_id': clientOrderId,
'staged_at': DateTime.utc(2026, 5, 25).toIso8601String(),
},
);
}
TradeActuator _testModeActuator({Guardrails? guardrails}) {
return TradeActuator(
tradingConfigDb: testDb!.tradingConfigDb,
tradingStateDb: testDb!.userTradingStateDb,
tradeOrdersDb: testDb!.tradeOrdersDb,
questionsDb: testDb!.questionsDb,
guardrails: guardrails ?? Guardrails(),
);
}
group('TradeActuator (test mode)', () {
test('drains pending order → inserts trade_orders + removes from pending',
() async {
if (testDb == null) {
markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests');
return;
}
const String uid = 'actuator-test-mode-uid';
await _seedConfig(uid);
// Real questions row (FK target for trade_orders.question_id).
final Map<String, dynamic> question = await testDb!.questionsDb.createQuestion(
assignedUserId: uid,
questionText: 'Buy SPY?',
correctAnswer: 10,
sourceTag: 'trading:rule:dip_confirm',
pipelineKey: 'trading',
pipelineStep: 'dip_confirm:await_confirm',
);
final String questionId = question['id']! as String;
final String clientOrderId = '$uid-dip_confirm-$questionId';
await _stagePending(
uid,
clientOrderId: clientOrderId,
questionId: questionId,
ruleId: 'dip_confirm',
);
final TradeActuator actuator = _testModeActuator();
final TradeActuatorResult result =
await actuator.processPendingOrders(uid);
expect(result.submitted, <String>[clientOrderId]);
expect(result.rejected, isEmpty);
expect(result.errors, isEmpty);
final TradeOrder? saved =
await testDb!.tradeOrdersDb.findByClientOrderId(clientOrderId);
expect(saved, isNotNull);
expect(saved!.alpacaOrderId, 'test-$clientOrderId');
expect(saved.status, 'test_accepted');
expect(saved.symbol, 'SPY');
expect(saved.side, 'buy');
expect(saved.notionalUsd, 10);
expect(saved.questionId, questionId);
expect(saved.ruleId, 'dip_confirm');
final List<Map<String, dynamic>> remaining =
await testDb!.userTradingStateDb.listPendingOrders(uid);
expect(remaining, isEmpty);
});
test('idempotent: existing trade_orders row → no second insert', () async {
if (testDb == null) {
markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests');
return;
}
const String uid = 'actuator-idempotent-uid';
await _seedConfig(uid);
final Map<String, dynamic> question = await testDb!.questionsDb.createQuestion(
assignedUserId: uid,
questionText: 'Buy SPY?',
correctAnswer: 10,
sourceTag: 'trading:rule:dip_confirm',
pipelineKey: 'trading',
pipelineStep: 'dip_confirm:await_confirm',
);
final String questionId = question['id']! as String;
final String clientOrderId = '$uid-dip_confirm-$questionId';
// Pre-insert a trade_orders row to simulate a prior crashed actuator run.
await testDb!.tradeOrdersDb.insertOrder(
firebaseUid: uid,
clientOrderId: clientOrderId,
symbol: 'SPY',
side: 'buy',
orderType: 'market',
status: 'test_accepted',
alpacaOrderId: 'test-$clientOrderId',
notionalUsd: 10,
questionId: questionId,
ruleId: 'dip_confirm',
);
await _stagePending(
uid,
clientOrderId: clientOrderId,
questionId: questionId,
ruleId: 'dip_confirm',
);
final TradeActuator actuator = _testModeActuator();
final TradeActuatorResult result =
await actuator.processPendingOrders(uid);
expect(result.submitted, <String>[clientOrderId]);
expect(result.rejected, isEmpty);
// Still exactly one trade_orders row.
expect(
await testDb!.tradeOrdersDb.findByClientOrderId(clientOrderId),
isNotNull,
);
final List<Map<String, dynamic>> pending =
await testDb!.userTradingStateDb.listPendingOrders(uid);
expect(pending, isEmpty);
});
test('guardrail rejects when notional exceeds server ceiling', () async {
if (testDb == null) {
markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests');
return;
}
const String uid = 'actuator-guardrail-uid';
await _seedConfig(uid);
final Map<String, dynamic> question = await testDb!.questionsDb.createQuestion(
assignedUserId: uid,
questionText: 'Buy SPY?',
correctAnswer: 10,
sourceTag: 'trading:rule:dip_confirm',
pipelineKey: 'trading',
pipelineStep: 'dip_confirm:await_confirm',
);
final String questionId = question['id']! as String;
final String clientOrderId = '$uid-dip_confirm-$questionId';
await _stagePending(
uid,
clientOrderId: clientOrderId,
questionId: questionId,
ruleId: 'dip_confirm',
notional: 999,
);
final TradeActuator actuator = _testModeActuator(
guardrails: Guardrails(serverMaxNotionalUsd: 50),
);
final TradeActuatorResult result =
await actuator.processPendingOrders(uid);
expect(result.submitted, isEmpty);
expect(result.rejected, hasLength(1));
expect(
result.rejected.single.reason,
GuardrailRejectionReason.serverMaxNotionalUsdExceeded,
);
expect(
await testDb!.tradeOrdersDb.findByClientOrderId(clientOrderId),
isNull,
);
final List<Map<String, dynamic>> pending =
await testDb!.userTradingStateDb.listPendingOrders(uid);
expect(pending, isEmpty);
});
});
group('TradeActuator with mocked Alpaca client', () {
test('POSTs once and persists Alpaca order id + status', () async {
if (testDb == null) {
markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests');
return;
}
const String uid = 'actuator-alpaca-mock-uid';
await _seedConfig(uid);
final Map<String, dynamic> question = await testDb!.questionsDb.createQuestion(
assignedUserId: uid,
questionText: 'Buy SPY?',
correctAnswer: 10,
sourceTag: 'trading:rule:dip_confirm',
pipelineKey: 'trading',
pipelineStep: 'dip_confirm:await_confirm',
);
final String questionId = question['id']! as String;
final String clientOrderId = '$uid-dip_confirm-$questionId';
await _stagePending(
uid,
clientOrderId: clientOrderId,
questionId: questionId,
ruleId: 'dip_confirm',
);
final FixtureLoader fixtures = FixtureLoader();
final Map<String, dynamic> orderJson =
await fixtures.loadJson('alpaca_order_accepted.json');
orderJson['client_order_id'] = clientOrderId;
final MockHttpClient mock = MockHttpClient();
mock.whenPost(
'/v2/orders',
http.Response(
jsonEncode(orderJson),
201,
headers: <String, String>{'content-type': 'application/json'},
),
);
final AlpacaEnv env = AlpacaEnv.fromMap(<String, String>{
'ALPACA_API_KEY_ID': 'k',
'ALPACA_API_SECRET_KEY': 's',
'ALPACA_TRADING_BASE_URL': AlpacaEnv.defaultPaperTradingUrl,
});
final AlpacaTradingClient client =
AlpacaTradingClient(env: env, httpClient: mock);
final TradeActuator actuator = TradeActuator(
tradingConfigDb: testDb!.tradingConfigDb,
tradingStateDb: testDb!.userTradingStateDb,
tradeOrdersDb: testDb!.tradeOrdersDb,
questionsDb: testDb!.questionsDb,
guardrails: Guardrails(),
alpacaClient: client,
);
final TradeActuatorResult result =
await actuator.processPendingOrders(uid);
expect(result.submitted, <String>[clientOrderId]);
expect(mock.requests, hasLength(1));
expect(mock.requests.single.method, 'POST');
final TradeOrder? saved =
await testDb!.tradeOrdersDb.findByClientOrderId(clientOrderId);
expect(saved, isNotNull);
expect(saved!.alpacaOrderId, '904837e3-3b76-47ec-b432-046db621571b');
expect(saved.status, 'accepted');
expect(saved.symbol, 'SPY');
// Re-running should NOT POST again (idempotency via findByClientOrderId).
await _stagePending(
uid,
clientOrderId: clientOrderId,
questionId: questionId,
ruleId: 'dip_confirm',
);
final TradeActuatorResult repeat =
await actuator.processPendingOrders(uid);
expect(repeat.submitted, <String>[clientOrderId]);
expect(mock.requests, hasLength(1));
});
});
}