@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({ '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: { '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 active = await TradableAssetsDb(testDb!.connection) .listActiveTradableSymbols(); expect( active.toSet(), {'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: { '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); }); }