190 lines
6.4 KiB
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),
|
|
);
|
|
});
|
|
}
|