cyberhybridhub/server/test/integration/tradable_assets_sync_test.dart
2026-05-31 11:17:12 -05:00

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);
});
}