@Tags(['integration', 'postgres']) library; import 'package:cyberhybridhub_server/trading/trading_dev_actions.dart'; import 'package:cyberhybridhub_server/trading/trading_pipeline.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(); }); TradingDevActions _actions({DateTime Function()? clock}) { final TradingPipeline pipeline = TradingPipeline( questionsDb: testDb!.questionsDb, questionService: testDb!.questionService(), marketDataDb: testDb!.marketDataDb, tradingConfigDb: testDb!.tradingConfigDb, tradingStateDb: testDb!.userTradingStateDb, clock: clock, ); return TradingDevActions( questionsDb: testDb!.questionsDb, marketDataDb: testDb!.marketDataDb, tradingConfigDb: testDb!.tradingConfigDb, tradingPipeline: pipeline, clock: clock, ); } test('seeds dipped snapshots and forces a dip_confirm question', () async { if (testDb == null) { markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); return; } const String uid = 'force-fire-uid'; await testDb!.seedUser(uid); await testDb!.tradingConfigDb.upsertUserConfig( firebaseUid: uid, templateName: 'default_paper_watchlist', enabled: true, ); final ForceFireResult result = await _actions().forceFireDip(uid); expect(result.skipReason, isNull); expect(result.evaluation, isNotNull); expect(result.evaluation!.rulesFired, ['dip_confirm']); expect(result.evaluation!.questionsCreated, 1); expect(result.snapshots, hasLength(2)); expect( result.snapshots.map((SeededSnapshot s) => s.metric).toSet(), {'prev_close', 'last_trade'}, ); // The synthetic last_trade should be at least the rule's threshold below // the reference price (default_paper_watchlist uses threshold_pct=-1.5). final SeededSnapshot trade = result.snapshots.firstWhere((SeededSnapshot s) => s.metric == 'last_trade'); final SeededSnapshot ref = result.snapshots.firstWhere((SeededSnapshot s) => s.metric == 'prev_close'); final num pct = ((trade.price - ref.price) / ref.price) * 100; expect(pct, lessThan(-1.5), reason: 'forced last_trade should be more than 1.5% below ref'); final List> open = await testDb!.questionsDb.listUnansweredQuestions(uid); expect(open, hasLength(1)); expect(open.single['pipelineKey'], 'trading'); expect(open.single['pipelineStep'], 'dip_confirm:await_confirm'); }); test('reuses existing fresh ref snapshot and only inserts last_trade', () async { if (testDb == null) { markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); return; } const String uid = 'force-fire-reuse-uid'; await testDb!.seedUser(uid); await testDb!.tradingConfigDb.upsertUserConfig( firebaseUid: uid, templateName: 'default_paper_watchlist', enabled: true, ); await testDb!.marketDataDb.insertSnapshot( symbol: 'SPY', metric: 'prev_close', price: 600, asOf: DateTime.utc(2026, 5, 25, 20), ); final ForceFireResult result = await _actions().forceFireDip(uid); final SeededSnapshot ref = result.snapshots.firstWhere((SeededSnapshot s) => s.metric == 'prev_close'); expect(ref.created, isFalse, reason: 'existing prev_close should be reused, not overwritten'); expect(ref.price, 600); final SeededSnapshot trade = result.snapshots.firstWhere((SeededSnapshot s) => s.metric == 'last_trade'); expect(trade.created, isTrue); // 2.0% below 600 = 588 (overshoot 0.5% beyond threshold of -1.5%). expect(trade.price, closeTo(588, 0.01)); }); test('short-circuits when user has no config', () async { if (testDb == null) { markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); return; } const String uid = 'force-fire-noconfig-uid'; await testDb!.seedUser(uid); final ForceFireResult result = await _actions().forceFireDip(uid); expect(result.skipReason, 'no_config'); expect(result.snapshots, isEmpty); expect(result.evaluation, isNull); }); test('short-circuits when user config is disabled', () async { if (testDb == null) { markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); return; } const String uid = 'force-fire-disabled-uid'; await testDb!.seedUser(uid); await testDb!.tradingConfigDb.upsertUserConfig( firebaseUid: uid, templateName: 'default_paper_watchlist', enabled: false, ); final ForceFireResult result = await _actions().forceFireDip(uid); expect(result.skipReason, 'disabled'); }); test('clears prior unanswered trading question so a re-fire produces a fresh one', () async { if (testDb == null) { markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); return; } const String uid = 'force-fire-reuse-question-uid'; await testDb!.seedUser(uid); await testDb!.tradingConfigDb.upsertUserConfig( firebaseUid: uid, templateName: 'default_paper_watchlist', enabled: true, ); final TradingDevActions actions = _actions(); final ForceFireResult first = await actions.forceFireDip(uid); expect(first.evaluation!.questionsCreated, 1); final List> beforeSecond = await testDb!.questionsDb.listUnansweredQuestions(uid); expect(beforeSecond, hasLength(1)); final ForceFireResult second = await actions.forceFireDip(uid); // The first question was auto-answered (skipped) by forceFireDip before // evaluating; the cooldown guard then prevents a second fire on the same // call. We expect either a fresh question or a clear cooldown skip — the // important invariant is that the queue never grows beyond one open // trading question. final List> afterSecond = await testDb!.questionsDb.listUnansweredQuestions(uid); expect(afterSecond.length, lessThanOrEqualTo(1)); expect( second.evaluation!.questionsCreated + second.evaluation!.rulesSkipped.length, greaterThan(0), ); }); }