Skip to content
Draft
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
12 changes: 10 additions & 2 deletions lib/features/bullnym/data/bullnym_http_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1286,7 +1286,7 @@ class BullnymHttpClient implements BullnymClientPort {
twitter: _optionalString(json, 'twitter'),
instagram: _optionalString(json, 'instagram'),
kind: kind,
posMode: _requiredBool(json, 'pos_mode'),
posMode: kind == bullnymDonationPageKindPos,
enabled: _requiredBool(json, 'enabled'),
isArchived: _requiredBool(json, 'is_archived'),
avatarSha256: _optionalString(json, 'avatar_sha256'),
Expand Down Expand Up @@ -1795,7 +1795,7 @@ class BullnymHttpClient implements BullnymClientPort {
),
);
}
return [
final observations = <BullnymBitcoinDirectObservation>[
for (final raw in rawObservations)
if (raw is Map<String, dynamic>)
BullnymBitcoinDirectObservation(
Expand All @@ -1818,6 +1818,14 @@ class BullnymHttpClient implements BullnymClientPort {
),
),
];
if (observations.any((observation) => observation.rail != 'bitcoin')) {
throw const _BullnymClientException(
BullnymFailure.invalidServerResponse(
logMessage: 'Bitcoin observation has an unsupported rail',
),
);
}
return observations;
}
}

Expand Down
2 changes: 1 addition & 1 deletion lib/features/bullnym/domain/bullnym_donation_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const String bullnymDonationPageKindPos = 'pos';
/// The view NEVER echoes `ct_descriptor`, so this DTO does not carry it. JSON
/// keys mirror the server exactly: `display_currency`, `is_archived`,
/// `avatar_sha256`, `og_sha256`, `public_url`. `posMode` is not a wire field:
/// the client derives it from `kind`.
/// the server dropped `pos_mode` and the client derives it from `kind`.
class BullnymDonationPage {
final String nym;
final String header;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ class GetPaidTransactionHistoryCubit

final ListGetPaidTransactionsUsecase _listTransactions;
int _generation = 0;
Set<String> _seenCursors = const {};

GetPaidTransactionHistoryCubit({required this._listTransactions})
: super(const GetPaidTransactionHistoryState());
Expand All @@ -18,6 +19,7 @@ class GetPaidTransactionHistoryCubit

Future<void> refresh() async {
final generation = ++_generation;
_seenCursors = {''};
emit(
const GetPaidTransactionHistoryState(
status: GetPaidTransactionHistoryStatus.loading,
Expand Down Expand Up @@ -53,6 +55,16 @@ class GetPaidTransactionHistoryCubit
}

final generation = _generation;
if (_seenCursors.contains(cursor)) {
emit(
state.copyWith(
clearNextCursor: true,
isLoadingMore: false,
loadMoreFailed: true,
),
);
return;
}
emit(state.copyWith(isLoadingMore: true, loadMoreFailed: false));
final result = await _listTransactions.execute(
cursor: cursor,
Expand All @@ -61,6 +73,19 @@ class GetPaidTransactionHistoryCubit
if (_isStale(generation)) return;
switch (result) {
case Ok(:final value):
final pageCursors = {..._seenCursors, cursor};
if (value.nextCursor != null &&
pageCursors.contains(value.nextCursor)) {
emit(
state.copyWith(
clearNextCursor: true,
isLoadingMore: false,
loadMoreFailed: true,
),
);
return;
}
_seenCursors = pageCursors;
emit(
state.copyWith(
transactions: _mergeByStableKey(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -484,14 +484,21 @@ class InvoicesPayServiceDatasource implements InvoicesPayServicePort {
required DateTime invoiceExpiresAt,
required bool presentationMarksLate,
}) {
if (observation.rail != 'bitcoin') {
throw ArgumentError.value(
observation.rail,
'rail',
'Unsupported bitcoin observation rail',
);
}
final firstSeenAt = _fromUnix(observation.firstSeenAtUnix);
final eventState = invoicePaymentEventStateFromWire(
state: observation.state,
confirmations: observation.confirmations,
invoiceSettlement: invoiceSettlement,
);
return InvoicePaymentEvent(
rail: PaymentMethod.fromWire(observation.rail) ?? PaymentMethod.btc,
rail: PaymentMethod.btc,
amountSat: observation.amountSat,
firstSeenAt: firstSeenAt,
lastSeenAt: _fromUnix(observation.lastSeenAtUnix),
Expand Down
7 changes: 4 additions & 3 deletions lib/features/invoices/presentation/invoice_detail_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,10 @@ class InvoiceDetailState {
// must continue polling. Treating an empty projection as complete after a
// transient failure would permanently stop supervision updates.
if (fallbackSupervisionFailure != null) return false;
final invoiceTerminal = cancelFinalStatus != null
? cancelFinalStatus!.isTerminal
: snapshot?.isMonitoringComplete ?? false;
// A cancel response is not a settlement snapshot. A cancelled invoice
// can still have a payment race or unresolved settlement evidence, so
// polling may stop only after the status endpoint reports completion.
final invoiceTerminal = snapshot?.isMonitoringComplete ?? false;
if (!invoiceTerminal) return false;
if (fallbackSupervisions.isEmpty) return true;
return fallbackSupervisions.every(
Expand Down
83 changes: 81 additions & 2 deletions test/features/bullnym/bullnym_donation_page_contract_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,6 @@ Map<String, dynamic> _donationPageView({
'twitter': 'me',
'instagram': null,
'kind': kind,
'pos_mode': false,
'enabled': enabled,
'is_archived': isArchived,
'avatar_sha256': null,
Expand Down Expand Up @@ -455,6 +454,38 @@ void main() {
},
);

test('derives posMode from kind for a view without pos_mode', () async {
final stub = _stubDio([
_donationPageView(kind: 'payment_page'),
_donationPageView(kind: 'pos'),
]);
final facade = _facadeForClient(BullnymHttpClient.withDio(stub.dio));

final page = _unwrap(
await facade.getDonationPage(nym: 'alice', kind: 'payment_page'),
);
final pos = _unwrap(
await facade.getDonationPage(nym: 'alice', kind: 'pos'),
);

expect(page.kind, 'payment_page');
expect(page.posMode, isFalse);
expect(pos.kind, 'pos');
expect(pos.posMode, isTrue);
});

test('rejects a view missing the kind discriminator', () async {
final stub = _stubDio([_donationPageView()..remove('kind')]);
final facade = _facadeForClient(BullnymHttpClient.withDio(stub.dio));

expect(
_unwrapFailure(
await facade.getDonationPage(nym: 'alice', kind: 'payment_page'),
).kind,
BullnymFailureKind.invalidServerResponse,
);
});

test('maps DonationPageNotFound envelope to a typed rejection', () async {
final stub = _stubDio([
{
Expand Down Expand Up @@ -640,7 +671,12 @@ void main() {
final stub = _stubDio([
{
'currencies': [
{'code': 'USD', 'precision': 2},
{'code': 'CAD', 'precision': 2},
{'code': 'CRC', 'precision': 0},
{'code': 'EUR', 'precision': 2},
{'code': 'MXN', 'precision': 2},
{'code': 'ARS', 'precision': 2},
{'code': 'COP', 'precision': 0},
],
},
Expand All @@ -649,7 +685,15 @@ void main() {

final currencies = _unwrap(await facade.getSupportedCurrencies());

expect(currencies.currencies.map((c) => c.code), ['CAD', 'COP']);
expect(currencies.currencies.map((c) => c.code), [
'USD',
'CAD',
'CRC',
'EUR',
'MXN',
'ARS',
'COP',
]);
expect(currencies.currencies.last.precision, 0);
final request = stub.captured.requests.single;
expect(request.method, 'GET');
Expand Down Expand Up @@ -686,6 +730,41 @@ void main() {
final failure = _unwrapFailure(await facade.getSupportedCurrencies());
expect(failure.kind, BullnymFailureKind.invalidServerResponse);
});

test(
'rejects unsupported currency codes and non-canonical precision',
() async {
for (final currency in [
{'code': 'XYZ', 'precision': 2},
{'code': 'CAD', 'precision': 0},
]) {
final stub = _stubDio([
{
'currencies': [currency],
},
]);
final facade = _facadeForClient(BullnymHttpClient.withDio(stub.dio));

final failure = _unwrapFailure(await facade.getSupportedCurrencies());
expect(failure.kind, BullnymFailureKind.invalidServerResponse);
}
},
);

test('rejects duplicate currency entries', () async {
final stub = _stubDio([
{
'currencies': [
{'code': 'CAD', 'precision': 2},
{'code': 'CAD', 'precision': 2},
],
},
]);
final facade = _facadeForClient(BullnymHttpClient.withDio(stub.dio));

final failure = _unwrapFailure(await facade.getSupportedCurrencies());
expect(failure.kind, BullnymFailureKind.invalidServerResponse);
});
});

// T-POS-SIGN (pr26): the POS surface rides the SAME donation-page save/archive
Expand Down
15 changes: 15 additions & 0 deletions test/features/bullnym/bullnym_invoice_contract_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,21 @@ void main() {
expect(failure.kind, BullnymFailureKind.invalidServerResponse);
});

test(
'status fails closed when a bitcoin observation has an unknown rail',
() async {
final response = _statusView()
..['bitcoin_direct_observations'][0]['rail'] = 'future';
final failure = _unwrapFailure(
await BullnymHttpClient.withDio(
_stubDio([response]).dio,
).getInvoiceStatus(invoiceId: 'inv-1'),
);

expect(failure.kind, BullnymFailureKind.invalidServerResponse);
},
);

test('status keeps an exact Bitcoin amount when BIP21 is absent', () async {
final response = _statusView()..['bitcoin_chain_bip21'] = null;
final client = BullnymHttpClient.withDio(_stubDio([response]).dio);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,9 +120,21 @@ void main() {
),
),
);
when(
() => list.execute(cursor: 'page-2', limit: 20),
).thenAnswer((_) async => const Err(GetPaidFailure.unavailable()));
var attempts = 0;
when(() => list.execute(cursor: 'page-2', limit: 20)).thenAnswer((
_,
) async {
attempts++;
if (attempts == 1) {
return const Err(GetPaidFailure.unavailable());
}
return Ok(
GetPaidTransactionPage(
transactions: [_transaction(secondId)],
nextCursor: null,
),
);
});

await cubit.load();
await cubit.loadMore();
Expand All @@ -132,6 +144,57 @@ void main() {
expect(cubit.state.nextCursor, 'page-2');
expect(cubit.state.isLoadingMore, isFalse);
expect(cubit.state.loadMoreFailed, isTrue);

await cubit.loadMore();

expect(cubit.state.transactions.map((item) => item.transactionId), [
firstId,
secondId,
]);
expect(cubit.state.nextCursor, isNull);
expect(cubit.state.loadMoreFailed, isFalse);
expect(attempts, 2);
},
);

test(
'rejects a non-adjacent cursor cycle without losing loaded rows',
() async {
when(() => list.execute(cursor: '', limit: 20)).thenAnswer(
(_) async => Ok(
GetPaidTransactionPage(
transactions: [_transaction(firstId)],
nextCursor: 'page-2',
),
),
);
when(() => list.execute(cursor: 'page-2', limit: 20)).thenAnswer(
(_) async => Ok(
GetPaidTransactionPage(
transactions: [_transaction(secondId)],
nextCursor: 'page-3',
),
),
);
when(() => list.execute(cursor: 'page-3', limit: 20)).thenAnswer(
(_) async => Ok(
GetPaidTransactionPage(
transactions: [_transaction(thirdId)],
nextCursor: 'page-2',
),
),
);

await cubit.load();
await cubit.loadMore();
await cubit.loadMore();

expect(cubit.state.transactions.map((item) => item.transactionId), [
firstId,
secondId,
]);
expect(cubit.state.nextCursor, isNull);
expect(cubit.state.loadMoreFailed, isTrue);
},
);

Expand Down
29 changes: 29 additions & 0 deletions test/features/invoices/invoice_detail_cubit_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,35 @@ void main() {
},
);

test(
'cancel does not stop settlement polling before status completion',
() async {
var statusCalls = 0;
when(() => facade.status(any())).thenAnswer((_) async {
statusCalls++;
return Ok(_snapshot(InvoiceStatus.unpaid));
});
when(() => facade.cancel(any())).thenAnswer(
(_) async => Ok(
CancelInvoiceResult(
invoiceId: InvoiceId('inv-1'),
finalStatus: InvoiceStatus.cancelled,
),
),
);

final cubit = build(initial: const Duration(milliseconds: 5));
await cubit.load();
await cubit.cancel();
await Future<void>.delayed(const Duration(milliseconds: 25));

expect(cubit.state.cancelFinalStatus, InvoiceStatus.cancelled);
expect(cubit.state.isTerminal, isFalse);
expect(statusCalls, greaterThan(2));
await cubit.close();
},
);

test('fiat detail explicitly loads the first available quote rail', () async {
final now = DateTime.utc(2026, 1, 1, 12);
when(() => facade.status(any())).thenAnswer(
Expand Down
Loading