From c479ce019ed30d084fbc240d0451423196dc76e5 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 5 Jun 2026 19:30:45 +0200 Subject: [PATCH] fix(price): tolerate unpriced /realunit/price payloads without crashing The live /v1/realunit/price endpoint returns only {"timestamp": ...} while no price is published for the day, and the newest /v1/realunit/price/history entry carries the same unpriced shape. The inline parsing in DFXPriceService multiplied these missing fields (null * 100), throwing an unhandled NoSuchMethodError from DashboardBloc on every dashboard price refresh (reproducible on DEV and PRD). Root fix: - Add RealUnitPriceDto with nullable chf/eur/timestamp so the wire shape is parsed in one typed, null-safe place (no more inline JSON parsing in the service). - getPriceOfAsset returns BigInt.zero when the requested currency is absent; the dashboard already renders the zero sentinel as "--.--". - getPriceChart skips entries that are unpriced or carry no parseable timestamp instead of crashing; partial/empty lists are already handled by PriceChartCubit. - getChfToEurRate falls back to 0.0 via the existing chf > 0 guard. Tests pin every new branch (unpriced spot payload per currency, skipped history entries, missing/invalid timestamps, DTO parsing); scoped coverage stays at 100%. A new dashboard golden (dashboard_price_no_chart) locks the "price present, history unpriced" rendering. --- .../service/dfx/dfx_price_service.dart | 47 ++++++------ .../models/price/dto/real_unit_price_dto.dart | 25 +++++++ .../dashboard/dashboard_golden_test.dart | 16 ++++ .../macos/dashboard_price_no_chart.png | Bin 0 -> 23238 bytes .../service/dfx/dfx_price_service_test.dart | 69 ++++++++++++++++++ .../price/dto/real_unit_price_dto_test.dart | 67 +++++++++++++++++ 6 files changed, 203 insertions(+), 21 deletions(-) create mode 100644 lib/packages/service/dfx/models/price/dto/real_unit_price_dto.dart create mode 100644 test/goldens/screens/dashboard/goldens/macos/dashboard_price_no_chart.png create mode 100644 test/packages/service/dfx/models/price/dto/real_unit_price_dto_test.dart 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 0000000000000000000000000000000000000000..5807ae9739e1f7ae627b8ef11d989adbac23a9b4 GIT binary patch literal 23238 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 literal 0 HcmV?d00001 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); + }); + }); +}