diff --git a/lib/packages/service/dfx/dfx_price_service.dart b/lib/packages/service/dfx/dfx_price_service.dart index dba6fe7b..059ddcf8 100644 --- a/lib/packages/service/dfx/dfx_price_service.dart +++ b/lib/packages/service/dfx/dfx_price_service.dart @@ -4,6 +4,7 @@ import 'package:realunit_wallet/models/asset.dart'; import 'package:realunit_wallet/models/price_point.dart'; import 'package:realunit_wallet/packages/config/api_config.dart'; import 'package:realunit_wallet/packages/service/app_store.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/price/dto/real_unit_price_dto.dart'; import 'package:realunit_wallet/packages/service/price_service.dart'; import 'package:realunit_wallet/styles/currency.dart'; @@ -17,6 +18,11 @@ class DFXPriceService extends APriceService { String get _host => _appStore.apiConfig.apiHost; + double? _priceFor(RealUnitPriceDto dto, Currency currency) => switch (currency) { + Currency.eur => dto.eur, + Currency.chf => dto.chf, + }; + @override Future> getPriceChart(Asset asset, Currency currency) async { final uri = buildUri(_host, _priceHistoryPath, {'timeFrame': 'ALL'}); @@ -29,20 +35,19 @@ class DFXPriceService extends APriceService { final result = []; for (final entry in body) { - BigInt price; - switch (currency) { - case Currency.eur: - price = BigInt.from(entry['eur'] * 100); - break; - case Currency.chf: - price = BigInt.from(entry['chf'] * 100); - break; - } + final dto = RealUnitPriceDto.fromJson(entry as Map); + final price = _priceFor(dto, currency); + + // Skip points the backend has not priced yet (e.g. the current day before + // the daily fixing). A null value would otherwise crash the multiplication, + // and a point without a timestamp cannot be placed on the time axis. + if (price == null || dto.timestamp == null) continue; + result.add( PricePoint( asset: asset, - price: price, - time: DateTime.parse(entry['timestamp']), + price: BigInt.from(price * 100), + time: dto.timestamp!, ), ); } @@ -57,14 +62,14 @@ class DFXPriceService extends APriceService { if (response.statusCode != 200) throw Exception(response.body); - final body = jsonDecode(response.body); + final dto = RealUnitPriceDto.fromJson(jsonDecode(response.body) as Map); + final price = _priceFor(dto, currency); - switch (currency) { - case Currency.eur: - return BigInt.from(body['eur'] * 100); - case Currency.chf: - return BigInt.from(body['chf'] * 100); - } + // The backend returns only a timestamp when no price is published yet; surface + // it as the zero sentinel the dashboard already renders as "--.--". + if (price == null) return BigInt.zero; + + return BigInt.from(price * 100); } /// Returns the equivalent EUR amount for 1 CHF @@ -74,9 +79,9 @@ class DFXPriceService extends APriceService { if (response.statusCode != 200) throw Exception(response.body); - final body = jsonDecode(response.body); - final chf = (body['chf'] as num).toDouble(); - final eur = (body['eur'] as num).toDouble(); + final dto = RealUnitPriceDto.fromJson(jsonDecode(response.body) as Map); + final chf = dto.chf ?? 0; + final eur = dto.eur ?? 0; return chf > 0 ? eur / chf : 0.0; } diff --git a/lib/packages/service/dfx/models/price/dto/real_unit_price_dto.dart b/lib/packages/service/dfx/models/price/dto/real_unit_price_dto.dart new file mode 100644 index 00000000..b9cac9a1 --- /dev/null +++ b/lib/packages/service/dfx/models/price/dto/real_unit_price_dto.dart @@ -0,0 +1,25 @@ +/// Wire shape of `/v1/realunit/price` and each `/v1/realunit/price/history` +/// entry. The backend omits `chf`/`eur` while no price is published for the +/// timestamp (e.g. the current day before the daily fixing), so both values +/// are nullable. `timestamp` is absent on some spot responses and therefore +/// nullable as well. +class RealUnitPriceDto { + final double? chf; + final double? eur; + final DateTime? timestamp; + + const RealUnitPriceDto({ + required this.chf, + required this.eur, + required this.timestamp, + }); + + factory RealUnitPriceDto.fromJson(Map json) { + final timestamp = json['timestamp']; + return RealUnitPriceDto( + chf: (json['chf'] as num?)?.toDouble(), + eur: (json['eur'] as num?)?.toDouble(), + timestamp: timestamp is String ? DateTime.tryParse(timestamp) : null, + ); + } +} diff --git a/test/goldens/screens/dashboard/dashboard_golden_test.dart b/test/goldens/screens/dashboard/dashboard_golden_test.dart index 69bb26cb..ed2679de 100644 --- a/test/goldens/screens/dashboard/dashboard_golden_test.dart +++ b/test/goldens/screens/dashboard/dashboard_golden_test.dart @@ -125,5 +125,21 @@ void main() { return wrapForGolden(buildSubject()); }, ); + + goldenTest( + // State produced when `/v1/realunit/price` carries a price but every + // history entry is still unpriced: DFXPriceService skips the null + // entries, so the price renders while the chart stays empty. The + // inverse state (no price at all -> "--.--") is `dashboard_empty`. + 'with price, unpriced history (chart empty)', + fileName: 'dashboard_price_no_chart', + constraints: const BoxConstraints.tightFor(width: 390, height: 844), + builder: () { + when(() => dashboardBloc.state).thenReturn( + emptyDashboardState().copyWith(price: BigInt.from(11300)), + ); + return wrapForGolden(buildSubject()); + }, + ); }); } diff --git a/test/goldens/screens/dashboard/goldens/macos/dashboard_price_no_chart.png b/test/goldens/screens/dashboard/goldens/macos/dashboard_price_no_chart.png new file mode 100644 index 00000000..5807ae97 Binary files /dev/null and b/test/goldens/screens/dashboard/goldens/macos/dashboard_price_no_chart.png differ diff --git a/test/packages/service/dfx/dfx_price_service_test.dart b/test/packages/service/dfx/dfx_price_service_test.dart index 81bbbb0a..b00c1797 100644 --- a/test/packages/service/dfx/dfx_price_service_test.dart +++ b/test/packages/service/dfx/dfx_price_service_test.dart @@ -60,6 +60,30 @@ void main() { throwsException, ); }); + + test('returns zero when CHF is absent (unpriced timestamp-only payload)', () async { + // The live endpoint returns `{"timestamp": ...}` while no price is + // published. The dashboard renders BigInt.zero as "--.--". + final client = MockClient((_) async => http.Response( + jsonEncode({'timestamp': '2026-06-05T08:20:40Z'}), + 200, + )); + + final price = await build(client).getPriceOfAsset(realUnitAsset, Currency.chf); + + expect(price, BigInt.zero); + }); + + test('returns zero when EUR is absent (unpriced timestamp-only payload)', () async { + final client = MockClient((_) async => http.Response( + jsonEncode({'timestamp': '2026-06-05T08:20:40Z'}), + 200, + )); + + final price = await build(client).getPriceOfAsset(realUnitAsset, Currency.eur); + + expect(price, BigInt.zero); + }); }); group('getPriceChart', () { @@ -107,6 +131,40 @@ void main() { throwsException, ); }); + + test('skips entries whose requested currency price is null', () async { + // The newest history entry of the live endpoint is unpriced + // (`{"timestamp": ...}` only). It must be skipped, not crash. + final client = MockClient((_) async => http.Response( + jsonEncode([ + {'chf': 1.0, 'eur': 0.95, 'timestamp': '2026-01-01T00:00:00Z'}, + {'eur': 2.30, 'timestamp': '2026-02-01T00:00:00Z'}, // no chf + {'timestamp': '2026-03-01T00:00:00Z'}, // unpriced + ]), + 200, + )); + + final points = await build(client).getPriceChart(realUnitAsset, Currency.chf); + + expect(points, hasLength(1)); + expect(points.single.price, BigInt.from(100)); + expect(points.single.time, DateTime.utc(2026, 1, 1)); + }); + + test('skips entries without a parseable timestamp', () async { + final client = MockClient((_) async => http.Response( + jsonEncode([ + {'chf': 1.0, 'eur': 0.95}, // no timestamp + {'chf': 2.0, 'eur': 1.90, 'timestamp': '2026-02-01T00:00:00Z'}, + ]), + 200, + )); + + final points = await build(client).getPriceChart(realUnitAsset, Currency.chf); + + expect(points, hasLength(1)); + expect(points.single.price, BigInt.from(200)); + }); }); group('getChfToEurRate', () { @@ -131,6 +189,17 @@ void main() { expect(rate, 0.0); }); + + test('returns 0.0 when the payload is unpriced (chf/eur absent)', () async { + final client = MockClient((_) async => http.Response( + jsonEncode({'timestamp': '2026-06-05T08:20:40Z'}), + 200, + )); + + final rate = await build(client).getChfToEurRate(); + + expect(rate, 0.0); + }); }); }); } diff --git a/test/packages/service/dfx/models/price/dto/real_unit_price_dto_test.dart b/test/packages/service/dfx/models/price/dto/real_unit_price_dto_test.dart new file mode 100644 index 00000000..13f226a7 --- /dev/null +++ b/test/packages/service/dfx/models/price/dto/real_unit_price_dto_test.dart @@ -0,0 +1,67 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/price/dto/real_unit_price_dto.dart'; + +void main() { + group('$RealUnitPriceDto', () { + test('parses chf, eur, and timestamp from a fully populated payload', () { + final dto = RealUnitPriceDto.fromJson({ + 'chf': 1.13, + 'eur': 1.05, + 'timestamp': '2026-06-05T00:00:00Z', + }); + + expect(dto.chf, 1.13); + expect(dto.eur, 1.05); + expect(dto.timestamp, DateTime.utc(2026, 6, 5)); + }); + + test('coerces integer price values to double', () { + final dto = RealUnitPriceDto.fromJson({ + 'chf': 1, + 'eur': 2, + 'timestamp': '2026-06-05T00:00:00Z', + }); + + expect(dto.chf, 1.0); + expect(dto.eur, 2.0); + }); + + test('parses a timestamp-only payload (unpriced) to all-null prices', () { + // The live endpoint returns exactly this shape while no price is + // published for the timestamp — the bug this DTO guards against. + final dto = RealUnitPriceDto.fromJson({ + 'timestamp': '2026-06-05T08:20:40.232Z', + }); + + expect(dto.chf, isNull); + expect(dto.eur, isNull); + expect(dto.timestamp, DateTime.parse('2026-06-05T08:20:40.232Z')); + }); + + test('timestamp is null when absent', () { + final dto = RealUnitPriceDto.fromJson({'chf': 1.13, 'eur': 1.05}); + + expect(dto.timestamp, isNull); + }); + + test('timestamp is null when not a string', () { + final dto = RealUnitPriceDto.fromJson({ + 'chf': 1.13, + 'eur': 1.05, + 'timestamp': 1749081600000, + }); + + expect(dto.timestamp, isNull); + }); + + test('timestamp is null when not parseable as a date', () { + final dto = RealUnitPriceDto.fromJson({ + 'chf': 1.13, + 'eur': 1.05, + 'timestamp': 'not-a-date', + }); + + expect(dto.timestamp, isNull); + }); + }); +}