@Tags(['integration', 'postgres']) library; import 'package:cyberhybridhub_server/pipeline/question_pipeline.dart'; import 'package:cyberhybridhub_server/trading/trading_pipeline.dart'; import 'package:postgres/postgres.dart'; import 'package:test/test.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(); }); final DateTime _testNow = DateTime.utc(2026, 5, 23, 14, 30); Future _seedSpyDipSnapshots({DateTime? tradeAsOf}) async { final DateTime asOfTrade = tradeAsOf ?? _testNow.subtract(const Duration(minutes: 1)); final DateTime asOfPrev = DateTime.utc(2026, 5, 22, 20); await testDb!.marketDataDb.insertSnapshot( symbol: 'SPY', metric: 'prev_close', price: 500, asOf: asOfPrev, ); await testDb!.marketDataDb.insertSnapshot( symbol: 'SPY', metric: 'last_trade', price: 492, asOf: asOfTrade, ); } Future _pipeline({DateTime? clock}) async { final DateTime fixed = clock ?? _testNow; return TradingPipeline( questionsDb: testDb!.questionsDb, questionService: testDb!.questionService(), marketDataDb: testDb!.marketDataDb, tradingConfigDb: testDb!.tradingConfigDb, tradingStateDb: testDb!.userTradingStateDb, clock: () => fixed, ); } group('TradingPipeline.evaluate', () { test('creates pipeline_key=trading question when SPY dip rule fires', () async { if (testDb == null) { markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); return; } const String uid = 'trading-evaluate-uid'; await testDb!.seedUser(uid); await testDb!.tradingConfigDb.upsertUserConfig( firebaseUid: uid, templateName: 'default_paper_watchlist', enabled: true, ); await _seedSpyDipSnapshots(); final TradingPipeline pipeline = await _pipeline(); final TradingEvaluationResult result = await pipeline.evaluate(uid); expect(result.questionsCreated, 1); expect(result.rulesFired, ['dip_confirm']); final Result rows = await testDb!.connection.execute( Sql.named( ''' SELECT pipeline_key, pipeline_step, source_tag, correct_answer, question_text FROM questions WHERE assigned_user_id = @uid ''', ), parameters: {'uid': uid}, ); expect(rows, hasLength(1)); final ResultRow row = rows.first; expect(row[0], 'trading'); expect(row[1], 'dip_confirm:await_confirm'); expect(row[2], 'trading:rule:dip_confirm'); expect(num.parse(row[3]!.toString()).toInt(), 10); expect(row[4] as String, contains('SPY')); final Map? ruleState = await testDb!.userTradingStateDb.getRuleState(uid, 'dip_confirm'); expect(ruleState, isNotNull); expect(ruleState!['phase'], 'await_confirm'); expect(ruleState['question_id'], isA()); }); test('does not double-fire when an open await_confirm question exists', () async { if (testDb == null) { markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); return; } const String uid = 'trading-evaluate-once-uid'; await testDb!.seedUser(uid); await testDb!.tradingConfigDb.upsertUserConfig( firebaseUid: uid, templateName: 'default_paper_watchlist', enabled: true, ); await _seedSpyDipSnapshots(); // Same clock + same fresh snapshots: the open-question guard must catch // the second call before the rule engine's cooldown does. final TradingPipeline pipeline = await _pipeline(); await pipeline.evaluate(uid); final TradingEvaluationResult result = await pipeline.evaluate(uid); expect(result.questionsCreated, 0); expect(result.rulesSkipped.first, contains('open_question')); }); test('skips rule via cooldown when same-day last_fired_at exists', () async { if (testDb == null) { markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); return; } const String uid = 'trading-evaluate-cooldown-uid'; await testDb!.seedUser(uid); await testDb!.tradingConfigDb.upsertUserConfig( firebaseUid: uid, templateName: 'default_paper_watchlist', enabled: true, ); await _seedSpyDipSnapshots(); final TradingPipeline pipeline = await _pipeline(); await pipeline.evaluate(uid); // Answer (and so close) the await_confirm question so the "open // question" guard doesn't dominate; we want to see the cooldown path. final List> open = await testDb!.questionsDb.listUnansweredQuestions(uid); await testDb!.questionsDb.submitAnswer( questionId: open.single['id'] as String, assignedUserId: uid, userResponse: -10, ); final TradingEvaluationResult again = await pipeline.evaluate(uid); expect(again.questionsCreated, 0); expect( again.rulesSkipped.single, contains('cooldown'), ); }); }); group('TradingPipeline.handleAnswer', () { test('+10 stages a pending order in user_trading_state.context', () async { if (testDb == null) { markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); return; } const String uid = 'trading-answer-yes-uid'; await testDb!.seedUser(uid); await testDb!.tradingConfigDb.upsertUserConfig( firebaseUid: uid, templateName: 'default_paper_watchlist', enabled: true, ); await _seedSpyDipSnapshots(); final TradingPipeline pipeline = await _pipeline(); await pipeline.evaluate(uid); final List> open = await testDb!.questionsDb.listUnansweredQuestions(uid); final Map? updated = await testDb!.questionsDb.submitAnswer( questionId: open.single['id'] as String, assignedUserId: uid, userResponse: 10, ); await pipeline.handleAnswer( firebaseUid: uid, answeredQuestion: updated!, userResponse: 10, ); final List> pending = await testDb!.userTradingStateDb.listPendingOrders(uid); expect(pending, hasLength(1)); expect(pending.single['symbol'], 'SPY'); expect(pending.single['side'], 'buy'); expect(pending.single['notional_usd'], 10); expect( pending.single['client_order_id'], '$uid-dip_confirm-${open.single['id']}', ); final Map? ruleState = await testDb!.userTradingStateDb.getRuleState(uid, 'dip_confirm'); expect(ruleState!['phase'], 'submit_order'); expect(ruleState['answer'], 'yes'); }); test('-10 records skip and does not stage an order', () async { if (testDb == null) { markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); return; } const String uid = 'trading-answer-no-uid'; await testDb!.seedUser(uid); await testDb!.tradingConfigDb.upsertUserConfig( firebaseUid: uid, templateName: 'default_paper_watchlist', enabled: true, ); await _seedSpyDipSnapshots(); final TradingPipeline pipeline = await _pipeline(); await pipeline.evaluate(uid); final List> open = await testDb!.questionsDb.listUnansweredQuestions(uid); final Map? updated = await testDb!.questionsDb.submitAnswer( questionId: open.single['id'] as String, assignedUserId: uid, userResponse: -10, ); await pipeline.handleAnswer( firebaseUid: uid, answeredQuestion: updated!, userResponse: -10, ); final List> pending = await testDb!.userTradingStateDb.listPendingOrders(uid); expect(pending, isEmpty); final Map? ruleState = await testDb!.userTradingStateDb.getRuleState(uid, 'dip_confirm'); expect(ruleState!['phase'], 'done'); expect(ruleState['answer'], 'no'); }); }); group('QuestionPipeline.onAnswerSubmitted delegation', () { test('routes pipeline_key=trading answer to TradingPipeline.handleAnswer', () async { if (testDb == null) { markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); return; } const String uid = 'trading-delegation-uid'; await testDb!.seedUser(uid); await testDb!.tradingConfigDb.upsertUserConfig( firebaseUid: uid, templateName: 'default_paper_watchlist', enabled: true, ); await _seedSpyDipSnapshots(); final TradingPipeline tradingPipeline = await _pipeline(); final QuestionPipeline questionPipeline = QuestionPipeline( questionsDb: testDb!.questionsDb, questionService: testDb!.questionService(), tradingPipeline: tradingPipeline, ); await tradingPipeline.evaluate(uid); final List> open = await testDb!.questionsDb.listUnansweredQuestions(uid); final Map? updated = await testDb!.questionsDb.submitAnswer( questionId: open.single['id'] as String, assignedUserId: uid, userResponse: 10, ); await questionPipeline.onAnswerSubmitted( firebaseUid: uid, answeredQuestion: updated!, userResponse: 10, ); final List> pending = await testDb!.userTradingStateDb.listPendingOrders(uid); expect(pending, hasLength(1)); }); }); }