307 lines
9.9 KiB
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));
|
|
});
|
|
});
|
|
}
|