cyberhybridhub/server/test/integration/trading_dev_actions_test.dart

190 lines
6.4 KiB
Dart

@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, <String>['dip_confirm']);
expect(result.evaluation!.questionsCreated, 1);
expect(result.snapshots, hasLength(2));
expect(
result.snapshots.map((SeededSnapshot s) => s.metric).toSet(),
<String>{'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<Map<String, dynamic>> 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<Map<String, dynamic>> 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<Map<String, dynamic>> afterSecond =
await testDb!.questionsDb.listUnansweredQuestions(uid);
expect(afterSecond.length, lessThanOrEqualTo(1));
expect(
second.evaluation!.questionsCreated +
second.evaluation!.rulesSkipped.length,
greaterThan(0),
);
});
}