cyberhybridhub/server/test/integration/trading_pipeline_test.dart

307 lines
9.9 KiB
Dart

@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<void> _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<TradingPipeline> _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, <String>['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: <String, dynamic>{'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<String, dynamic>? ruleState =
await testDb!.userTradingStateDb.getRuleState(uid, 'dip_confirm');
expect(ruleState, isNotNull);
expect(ruleState!['phase'], 'await_confirm');
expect(ruleState['question_id'], isA<String>());
});
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<Map<String, dynamic>> 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<Map<String, dynamic>> open =
await testDb!.questionsDb.listUnansweredQuestions(uid);
final Map<String, dynamic>? 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<Map<String, dynamic>> 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<String, dynamic>? 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<Map<String, dynamic>> open =
await testDb!.questionsDb.listUnansweredQuestions(uid);
final Map<String, dynamic>? 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<Map<String, dynamic>> pending =
await testDb!.userTradingStateDb.listPendingOrders(uid);
expect(pending, isEmpty);
final Map<String, dynamic>? 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<Map<String, dynamic>> open =
await testDb!.questionsDb.listUnansweredQuestions(uid);
final Map<String, dynamic>? 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<Map<String, dynamic>> pending =
await testDb!.userTradingStateDb.listPendingOrders(uid);
expect(pending, hasLength(1));
});
});
}