178 lines
5.3 KiB
Dart
178 lines
5.3 KiB
Dart
@Tags(['integration', 'postgres'])
|
|
library;
|
|
|
|
import 'package:cyberhybridhub_server/alpaca/alpaca_assets_client.dart';
|
|
import 'package:cyberhybridhub_server/alpaca/alpaca_env.dart';
|
|
import 'package:cyberhybridhub_server/trading/tradable_assets_db.dart';
|
|
import 'package:cyberhybridhub_server/trading/tradable_assets_sync.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:postgres/postgres.dart';
|
|
import 'package:test/test.dart';
|
|
|
|
import '../helpers/fixture_loader.dart';
|
|
import '../helpers/mock_http_client.dart';
|
|
import '../helpers/test_db.dart';
|
|
|
|
void main() {
|
|
TestDb? testDb;
|
|
late FixtureLoader fixtures;
|
|
late AlpacaEnv env;
|
|
|
|
setUpAll(() async {
|
|
testDb = await TestDb.open();
|
|
fixtures = FixtureLoader();
|
|
env = AlpacaEnv.fromMap(<String, String>{
|
|
'ALPACA_API_KEY_ID': 'test-key',
|
|
'ALPACA_API_SECRET_KEY': 'test-secret',
|
|
'ALPACA_TRADING_BASE_URL': 'https://paper-api.alpaca.markets',
|
|
});
|
|
});
|
|
|
|
tearDown(() async {
|
|
if (testDb != null) {
|
|
await testDb!.truncateTradingTables();
|
|
}
|
|
});
|
|
|
|
tearDownAll(() async {
|
|
await testDb?.close();
|
|
});
|
|
|
|
TradableAssetsSync makeSync({required MockHttpClient mock}) {
|
|
return TradableAssetsSync(
|
|
assetsClient: AlpacaAssetsClient(env: env, httpClient: mock),
|
|
assetsDb: TradableAssetsDb(testDb!.connection),
|
|
connection: testDb!.connection,
|
|
);
|
|
}
|
|
|
|
test(
|
|
'runOnce with the fixture upserts 5 assets and writes a universe '
|
|
'sync_runs row with finished_at',
|
|
() async {
|
|
if (testDb == null) {
|
|
markTestSkipped(
|
|
'Set DATABASE_URL or TEST_DATABASE_URL for integration tests',
|
|
);
|
|
return;
|
|
}
|
|
|
|
final String body = await fixtures.loadString('alpaca_assets_active.json');
|
|
final MockHttpClient mock = MockHttpClient()
|
|
..whenGet(
|
|
'/v2/assets',
|
|
http.Response(body, 200, headers: <String, String>{
|
|
'content-type': 'application/json',
|
|
}),
|
|
);
|
|
|
|
final TradableAssetsSync sync = makeSync(mock: mock);
|
|
final TradableAssetsSyncResult result = await sync.runOnce();
|
|
|
|
expect(result.error, isNull);
|
|
expect(result.rowsWritten, 5);
|
|
|
|
final List<String> active = await TradableAssetsDb(testDb!.connection)
|
|
.listActiveTradableSymbols();
|
|
expect(
|
|
active.toSet(),
|
|
<String>{'AAPL', 'MSFT', 'SPY', 'BRK.B'},
|
|
reason: 'PNKZZ has tradable=false and must be excluded',
|
|
);
|
|
|
|
final Result runs = await testDb!.connection.execute(
|
|
'''
|
|
SELECT kind, rows_written, rows_removed, error,
|
|
started_at, finished_at
|
|
FROM market_data_sync_runs
|
|
ORDER BY id ASC
|
|
''',
|
|
);
|
|
expect(runs, hasLength(1));
|
|
final ResultRow row = runs.first;
|
|
expect(row[0], 'universe');
|
|
expect((row[1]! as num).toInt(), 5);
|
|
expect((row[2]! as num).toInt(), 0);
|
|
expect(row[3], isNull, reason: 'no error on happy path');
|
|
expect(row[4], isNotNull);
|
|
expect(row[5], isNotNull);
|
|
});
|
|
|
|
test(
|
|
'runOnce records the error when the Alpaca client throws and does '
|
|
'not propagate the exception',
|
|
() async {
|
|
if (testDb == null) {
|
|
markTestSkipped(
|
|
'Set DATABASE_URL or TEST_DATABASE_URL for integration tests',
|
|
);
|
|
return;
|
|
}
|
|
|
|
final MockHttpClient mock = MockHttpClient()
|
|
..whenGet(
|
|
'/v2/assets',
|
|
http.Response('upstream exploded', 500),
|
|
);
|
|
|
|
final TradableAssetsSync sync = makeSync(mock: mock);
|
|
|
|
// Must not throw — failures get recorded, not raised.
|
|
final TradableAssetsSyncResult result = await sync.runOnce();
|
|
|
|
expect(result.rowsWritten, 0);
|
|
expect(result.error, isNotNull);
|
|
expect(result.error, contains('500'));
|
|
|
|
final Result runs = await testDb!.connection.execute(
|
|
'''
|
|
SELECT kind, rows_written, error, finished_at
|
|
FROM market_data_sync_runs
|
|
ORDER BY id ASC
|
|
''',
|
|
);
|
|
expect(runs, hasLength(1));
|
|
expect(runs.first[0], 'universe');
|
|
expect((runs.first[1]! as num).toInt(), 0);
|
|
expect(runs.first[2]?.toString(), contains('500'));
|
|
expect(runs.first[3], isNotNull,
|
|
reason: 'finished_at must always be set, even on failure');
|
|
});
|
|
|
|
test('two consecutive runs produce identical row counts (idempotent)',
|
|
() async {
|
|
if (testDb == null) {
|
|
markTestSkipped(
|
|
'Set DATABASE_URL or TEST_DATABASE_URL for integration tests',
|
|
);
|
|
return;
|
|
}
|
|
|
|
final String body = await fixtures.loadString('alpaca_assets_active.json');
|
|
final MockHttpClient mock = MockHttpClient()
|
|
..whenGet(
|
|
'/v2/assets',
|
|
http.Response(body, 200, headers: <String, String>{
|
|
'content-type': 'application/json',
|
|
}),
|
|
);
|
|
|
|
final TradableAssetsSync sync = makeSync(mock: mock);
|
|
final TradableAssetsSyncResult r1 = await sync.runOnce();
|
|
final TradableAssetsSyncResult r2 = await sync.runOnce();
|
|
|
|
expect(r1.rowsWritten, 5);
|
|
expect(r2.rowsWritten, 5);
|
|
expect(r1.error, isNull);
|
|
expect(r2.error, isNull);
|
|
|
|
final Result count = await testDb!.connection
|
|
.execute("SELECT COUNT(*) FROM tradable_assets WHERE status = 'active'");
|
|
expect((count.first[0]! as num).toInt(), 5);
|
|
|
|
final Result runs = await testDb!.connection
|
|
.execute('SELECT COUNT(*) FROM market_data_sync_runs');
|
|
expect((runs.first[0]! as num).toInt(), 2);
|
|
});
|
|
}
|