@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 _seedConfig(String uid) async { await testDb!.seedUser(uid); await testDb!.tradingConfigDb.upsertUserConfig( firebaseUid: uid, templateName: 'default_paper_watchlist', enabled: true, ); } Future _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: { '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 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, [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> 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 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, [clientOrderId]); expect(result.rejected, isEmpty); // Still exactly one trade_orders row. expect( await testDb!.tradeOrdersDb.findByClientOrderId(clientOrderId), isNotNull, ); final List> 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 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> 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 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 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: {'content-type': 'application/json'}, ), ); final AlpacaEnv env = AlpacaEnv.fromMap({ '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, [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, [clientOrderId]); expect(mock.requests, hasLength(1)); }); }); }