Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 26 additions & 21 deletions lib/packages/service/dfx/dfx_price_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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<List<PricePoint>> getPriceChart(Asset asset, Currency currency) async {
final uri = buildUri(_host, _priceHistoryPath, {'timeFrame': 'ALL'});
Expand All @@ -29,20 +35,19 @@ class DFXPriceService extends APriceService {
final result = <PricePoint>[];

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<String, dynamic>);
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!,
),
);
}
Expand All @@ -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<String, dynamic>);
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
Expand All @@ -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<String, dynamic>);
final chf = dto.chf ?? 0;
final eur = dto.eur ?? 0;

return chf > 0 ? eur / chf : 0.0;
}
Expand Down
25 changes: 25 additions & 0 deletions lib/packages/service/dfx/models/price/dto/real_unit_price_dto.dart
Original file line number Diff line number Diff line change
@@ -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<String, dynamic> 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,
);
}
}
16 changes: 16 additions & 0 deletions test/goldens/screens/dashboard/dashboard_golden_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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());
},
);
});
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
69 changes: 69 additions & 0 deletions test/packages/service/dfx/dfx_price_service_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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', () {
Expand Down Expand Up @@ -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', () {
Expand All @@ -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);
});
});
});
}
Original file line number Diff line number Diff line change
@@ -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);
});
});
}
Loading