Skip to content
Open
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
28 changes: 25 additions & 3 deletions lib/features/trades/providers/trades_providers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,10 @@ TradeStatusFilter orderStatusToFilter(rust_types.OrderStatus status) {
// ── Internal helpers ──────────────────────────────────────────────────────────

/// Converts a [rust_types.TradeInfo] to a [TradeListItem].
TradeListItem _tradeInfoToItem(rust_types.TradeInfo trade) {
TradeListItem _tradeInfoToItem(
rust_types.TradeInfo trade, {
TradeStatusFilter? statusOverride,
}) {
final fiatDisplay = _formatFiat(
trade.order.fiatAmount,
trade.order.fiatAmountMin,
Expand All @@ -128,7 +131,7 @@ TradeListItem _tradeInfoToItem(rust_types.TradeInfo trade) {
// TradeRole.buyer = the user is buying Bitcoin (took a sell order or
// created a buy order). TradeRole.seller = selling Bitcoin.
isSelling: trade.role == rust_types.TradeRole.seller,
status: orderStatusToFilter(trade.order.status),
status: statusOverride ?? orderStatusToFilter(trade.order.status),
// order.isMine is true when the local user published this order (maker).
role: trade.order.isMine ? TradeRole.creator : TradeRole.taker,
fiatAmount: fiatDisplay,
Expand Down Expand Up @@ -197,7 +200,26 @@ final filteredTradesWithOrderStateProvider =
final filter = ref.watch(selectedStatusFilterProvider);
final trades = await ref.watch(rawTradesProvider.future);

final items = trades.map(_tradeInfoToItem).toList();
// Bucket each trade by the SAME live status its row chip shows, so the filter
// and the chip can never disagree (issue #269). The persisted snapshot in the
// DB can lag behind gift-wrap / 38383 updates (and, for own orders, is not
// always synced back to Pending), so tradeStatusProvider is the single source
// of truth. Until the live status has loaded we fall back to the snapshot
// bucket, so nothing briefly escapes the active filter.
final items = trades.map((trade) {
// Terminal trades cannot change further, so don't create a long-lived 2s
// poller for them — mirroring the guard in orderBookNotificationCountProvider
// (#299 review). Their snapshot status is authoritative here: order status
// only ever moves toward terminal, so a snapshot can lag but never run ahead,
// and falling back to it (live == null) buckets them by their real status.
final live = _terminalOrderStatuses.contains(trade.order.status)
? null
: ref.watch(tradeStatusProvider(trade.order.id)).valueOrNull;
return _tradeInfoToItem(
trade,
statusOverride: live == null ? null : orderStatusToFilter(live),
);
}).toList();

final filtered = filter == TradeStatusFilter.all
? items
Expand Down
119 changes: 119 additions & 0 deletions test/features/trades/filtered_trades_provider_test.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import 'dart:async';

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mostro/features/trades/providers/trades_providers.dart';
import 'package:mostro/features/order/providers/trade_state_provider.dart';
import 'package:mostro/src/rust/api/types.dart' show TradeInfo, OrderStatus;

import '../../support/fake_trades.dart';
Expand Down Expand Up @@ -95,4 +98,120 @@ void main() {
});
});
});

group('live status buckets the filter (issue #269)', () {
// A trade whose persisted snapshot is Pending, but whose live status (the
// one the row chip shows) has already moved to waitingBuyerInvoice. The
// filter must follow the live status, not the stale snapshot.
ProviderContainer staleSnapshotContainer() => createContainer(overrides: [
rawTradesProvider.overrideWith(
(ref) async => [fakeTrade(id: 'x', status: OrderStatus.pending)],
),
tradeStatusProvider('order-x').overrideWith(
(ref) => Stream.value(OrderStatus.waitingBuyerInvoice),
),
]);

// Wait for the overridden tradeStatusProvider stream to emit its first
// value, so the derived filter provider sees the live status rather than
// racing the snapshot fallback (which only applies until live loads).
Future<void> primeLiveStatus(ProviderContainer c) async {
await c.read(tradeStatusProvider('order-x').future);
}

test('trade does NOT appear under the stale Pending bucket', () async {
final container = staleSnapshotContainer();
await primeLiveStatus(container);
expect(
await _orderIds(container, filter: TradeStatusFilter.pending),
isEmpty,
);
});

test('trade appears under the live Waiting Invoice bucket', () async {
final container = staleSnapshotContainer();
await primeLiveStatus(container);
expect(
await _orderIds(container, filter: TradeStatusFilter.waitingInvoice),
['order-x'],
);
});

test('falls back to the snapshot bucket until live status loads', () async {
// No tradeStatusProvider override: live is unavailable, so the snapshot
// status (Pending) is used. This preserves behaviour before first poll.
final container = createContainer(overrides: [
rawTradesProvider.overrideWith(
(ref) async => [fakeTrade(id: 'y', status: OrderStatus.pending)],
),
tradeStatusProvider('order-y').overrideWith(
(ref) => const Stream.empty(),
),
]);
expect(
await _orderIds(container, filter: TradeStatusFilter.pending),
['order-y'],
);
});

test('re-buckets live when the status transitions', () async {
// The core promise of #269: as a trade's live status changes, it moves
// between filter buckets in step with its chip. Drive the live status
// with a controllable stream and assert the trade leaves the old bucket
// and enters the new one.
final controller = StreamController<OrderStatus>();
addTearDown(controller.close);
final container = createContainer(overrides: [
rawTradesProvider.overrideWith(
(ref) async => [fakeTrade(id: 'z', status: OrderStatus.pending)],
),
tradeStatusProvider('order-z').overrideWith((ref) => controller.stream),
]);

// First live status: Pending. In the Pending bucket, not Waiting Invoice.
controller.add(OrderStatus.pending);
await container.read(tradeStatusProvider('order-z').future);
expect(
await _orderIds(container, filter: TradeStatusFilter.pending),
['order-z'],
);
expect(
await _orderIds(container, filter: TradeStatusFilter.waitingInvoice),
isEmpty,
);

// Transition to Waiting Invoice: leaves Pending, enters Waiting Invoice.
controller.add(OrderStatus.waitingBuyerInvoice);
await Future<void>.delayed(Duration.zero);
expect(
await _orderIds(container, filter: TradeStatusFilter.pending),
isEmpty,
);
expect(
await _orderIds(container, filter: TradeStatusFilter.waitingInvoice),
['order-z'],
);
});
test(
'a terminal trade keeps its snapshot bucket and ignores live status (#299)',
() async {
// A terminal trade must not spawn a live-status poller. Override its
// tradeStatusProvider with a NON-terminal live status: if the guard
// failed to skip the watch, the trade would follow that live status out
// of the success bucket. Because terminal trades bucket by snapshot, the
// override is never read, so it stays in success (#299 review).
final container = createContainer(overrides: [
rawTradesProvider.overrideWith(
(ref) async => [fakeTrade(id: 'done', status: OrderStatus.success)],
),
tradeStatusProvider('order-done').overrideWith(
(ref) => Stream.value(OrderStatus.pending),
),
]);
expect(
await _orderIds(container, filter: TradeStatusFilter.success),
['order-done'],
);
});
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Loading