A shop configured for the HTTP route uploaded 17 bills correctly today and never once appeared on the fleet board. Nothing logged it, because from the terminal's point of view nothing had failed: the reporter was typed against MqttOrderTransport and started behind an `is MqttOrderTransport` check, so on HTTP it was silently never constructed. A monitoring feature that quietly does not exist on one of two supported routes is worse than no feature, because the blank square reads as "no terminals" rather than "not wired up". publishHealth moves onto the OrderTransport interface. The broker publishes to the health topic as before; HTTP posts the same payload to POST /pos/health; the simulated route does nothing, which is the honest answer for a till with no back office configured. The reporter is now started for every route. There was no test for the reporter at all, which is why this shipped. There are five now, including one that fails on the old code. Also removes test/widget_test.dart — the stock `flutter create` counter test, referencing a MyApp that never existed here. It has never compiled and was the only red in the suite. 263 tests pass, analyzer clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
326 lines
11 KiB
Dart
326 lines
11 KiB
Dart
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:nearle_pos/data/datasources/local_store.dart';
|
|
import 'package:nearle_pos/data/datasources/seed_data.dart';
|
|
import 'package:nearle_pos/data/local/app_database.dart';
|
|
import 'package:nearle_pos/data/remote/order_transport.dart';
|
|
import 'package:nearle_pos/data/remote/simulated_catalogue_source.dart';
|
|
import 'package:nearle_pos/data/repositories/customer_repository_impl.dart';
|
|
import 'package:nearle_pos/data/repositories/product_repository_impl.dart';
|
|
import 'package:nearle_pos/data/repositories/sync_repository_impl.dart';
|
|
import 'package:nearle_pos/data/repositories/transaction_repository_impl.dart';
|
|
import 'package:nearle_pos/domain/entities/cart.dart';
|
|
import 'package:nearle_pos/domain/entities/customer.dart';
|
|
import 'package:nearle_pos/domain/entities/transaction.dart';
|
|
import 'package:nearle_pos/domain/usecases/checkout_sale.dart';
|
|
|
|
/// A shopper who signs up at the till has to reach the back office in their
|
|
/// own right. Before the customer outbox they only ever travelled as three
|
|
/// fields riding along on a bill — so somebody who registered and then bought
|
|
/// nothing, or whose bill was still queued, existed on one terminal and
|
|
/// nowhere else.
|
|
void main() {
|
|
late LocalStore store;
|
|
late CustomerRepositoryImpl customers;
|
|
late CheckoutSale checkout;
|
|
|
|
setUpAll(() {
|
|
LocalStore.registerSeed(
|
|
products: SeedData.products,
|
|
customers: SeedData.customers,
|
|
);
|
|
});
|
|
|
|
setUp(() async {
|
|
store = LocalStore.instance;
|
|
await store.reset(withCatalogue: true);
|
|
|
|
customers = CustomerRepositoryImpl(store);
|
|
checkout = CheckoutSale(
|
|
productRepository: ProductRepositoryImpl(store),
|
|
customerRepository: customers,
|
|
transactionRepository: TransactionRepositoryImpl(store),
|
|
);
|
|
});
|
|
|
|
tearDownAll(() => AppDatabase.instance.close());
|
|
|
|
SyncRepositoryImpl syncWith(OrderTransport transport) => SyncRepositoryImpl(
|
|
store,
|
|
SimulatedCatalogueSource(isOffline: () => false),
|
|
transport,
|
|
);
|
|
|
|
Future<Customer> register(String mobile, {String name = 'Meena'}) =>
|
|
customers.create(Customer(id: '', name: name, mobile: mobile));
|
|
|
|
group('identity', () {
|
|
test('the same mobile produces the same id on any terminal', () {
|
|
// The whole point. A hundred tills mint ids without talking to each
|
|
// other, so the id has to be a function of the shopper, not of chance.
|
|
expect(
|
|
Customer.idForMobile('9840012345'),
|
|
Customer.idForMobile('9840012345'),
|
|
);
|
|
});
|
|
|
|
test('formatting does not create a second shopper', () {
|
|
final plain = Customer.idForMobile('9840012345');
|
|
expect(Customer.idForMobile('+91 98400 12345'), plain);
|
|
expect(Customer.idForMobile('98400-12345'), plain);
|
|
});
|
|
|
|
test('the country code and the trunk prefix are stripped', () {
|
|
expect(Customer.normaliseMobile('+91 98400 12345'), '9840012345');
|
|
expect(Customer.normaliseMobile('098400 12345'), '9840012345');
|
|
expect(Customer.normaliseMobile('9840012345'), '9840012345');
|
|
});
|
|
|
|
test('a number the rule was not written for is left alone', () {
|
|
// Mangling something unrecognised is worse than storing it verbatim: a
|
|
// wrongly-trimmed number silently merges two different shoppers.
|
|
expect(Customer.normaliseMobile('4155550123'), '4155550123');
|
|
expect(Customer.normaliseMobile('12345'), '12345');
|
|
// Twelve digits that do not start with the Indian country code.
|
|
expect(Customer.normaliseMobile('442071234567'), '442071234567');
|
|
});
|
|
|
|
test('different shoppers get different ids', () {
|
|
expect(
|
|
Customer.idForMobile('9840012345'),
|
|
isNot(Customer.idForMobile('9840012346')),
|
|
);
|
|
});
|
|
|
|
test('a registration is keyed on the number, not on chance', () async {
|
|
final created = await register('+91 98400 12345');
|
|
expect(created.id, Customer.idForMobile('9840012345'));
|
|
expect(created.mobile, '9840012345');
|
|
});
|
|
});
|
|
|
|
group('the outbox', () {
|
|
test('a shopper registered at the till is queued', () async {
|
|
final before = await store.catalogue.unsyncedCustomerCount();
|
|
await register('9840012345');
|
|
expect(await store.catalogue.unsyncedCustomerCount(), before + 1);
|
|
});
|
|
|
|
test('a shopper who buys nothing still goes up', () async {
|
|
// The case that used to be lost entirely: no bill, so nothing to ride.
|
|
final created = await register('9840012345');
|
|
|
|
final transport = _RecordingTransport();
|
|
final outcome = await syncWith(transport).syncCustomers();
|
|
|
|
expect(outcome.uploaded, greaterThanOrEqualTo(1));
|
|
expect(transport.sentIds, contains(created.id));
|
|
});
|
|
|
|
test('shoppers that came from the back office are not posted back',
|
|
() async {
|
|
// The seed catalogue arrives as an import. Sending it straight back
|
|
// would be a round trip telling the server what it just told us.
|
|
final imported = store.customers.map((c) => c.id).toSet();
|
|
expect(imported, isNotEmpty, reason: 'the fixture needs seeded shoppers');
|
|
|
|
final pending = await store.catalogue.unsyncedCustomers(limit: 500);
|
|
expect(
|
|
pending.map((c) => c.id).toSet().intersection(imported),
|
|
isEmpty,
|
|
);
|
|
});
|
|
|
|
test('only the ids the back office names are marked sent', () async {
|
|
final kept = await register('9840012345', name: 'Meena');
|
|
final dropped = await register('9840099999', name: 'Ravi');
|
|
|
|
// Silence about a row is not acceptance of it.
|
|
final transport = _RecordingTransport(
|
|
accept: (ids) => ids.where((id) => id == kept.id).toList(),
|
|
);
|
|
await syncWith(transport).syncCustomers();
|
|
|
|
final stillPending =
|
|
(await store.catalogue.unsyncedCustomers(limit: 500))
|
|
.map((c) => c.id)
|
|
.toSet();
|
|
|
|
expect(stillPending, contains(dropped.id));
|
|
expect(stillPending, isNot(contains(kept.id)));
|
|
});
|
|
|
|
test('a batch nobody accepts stops rather than looping for ever',
|
|
() async {
|
|
await register('9840012345');
|
|
|
|
final transport = _RecordingTransport(accept: (_) => const []);
|
|
final outcome = await syncWith(transport).syncCustomers();
|
|
|
|
expect(outcome.isSuccess, isFalse);
|
|
expect(outcome.isRetryable, isFalse);
|
|
expect(transport.calls, 1, reason: 'the same page must not be re-read');
|
|
});
|
|
|
|
test('an unreachable back office leaves everyone pending', () async {
|
|
final created = await register('9840012345');
|
|
|
|
final outcome = await syncWith(_FailingTransport()).syncCustomers();
|
|
|
|
expect(outcome.isSuccess, isFalse);
|
|
expect(outcome.uploaded, 0);
|
|
expect(
|
|
(await store.catalogue.unsyncedCustomers(limit: 500))
|
|
.map((c) => c.id),
|
|
contains(created.id),
|
|
);
|
|
});
|
|
});
|
|
|
|
group('a sale does not disturb the outbox', () {
|
|
/// Rings a bill for [customer] so loyalty movement is written.
|
|
Future<void> ringSaleFor(Customer customer) async {
|
|
final product = store.products.first;
|
|
final cart = Cart(
|
|
lines: [CartLine(product: product, quantity: 1)],
|
|
customer: customer,
|
|
);
|
|
await checkout(
|
|
cart: cart,
|
|
payments: [
|
|
PaymentSplit(method: PaymentMethod.cash, amount: cart.grandTotal),
|
|
],
|
|
cashierName: 'Suriya',
|
|
terminalId: 'T4A9',
|
|
);
|
|
}
|
|
|
|
test('a sale does not re-queue a shopper already sent', () async {
|
|
final created = await register('9840012345');
|
|
await syncWith(_RecordingTransport()).syncCustomers();
|
|
expect(await store.catalogue.unsyncedCustomerCount(), 0);
|
|
|
|
await ringSaleFor(created);
|
|
|
|
// A sale writes the shopper's new points and spend. Done as an upsert
|
|
// that replaces the row, every column absent from it — sync_status
|
|
// included — would silently revert to its schema default.
|
|
expect(
|
|
await store.catalogue.unsyncedCustomerCount(),
|
|
0,
|
|
reason: 'loyalty movement is not a registration change',
|
|
);
|
|
});
|
|
|
|
test('a sale still moves the loyalty figures', () async {
|
|
// Guards the fix above from being "achieved" by not writing at all.
|
|
final created = await register('9840012345');
|
|
await ringSaleFor(created);
|
|
|
|
final after = await store.catalogue.customerById(created.id);
|
|
expect(after!.visitCount, 1);
|
|
expect(after.lifetimeSpend, greaterThan(0));
|
|
expect(after.lastVisitAt, isNotNull);
|
|
});
|
|
|
|
test('a sale does not overwrite a profile', () async {
|
|
final created = await register('9840012345', name: 'Meena');
|
|
await ringSaleFor(created);
|
|
|
|
final after = await store.catalogue.customerById(created.id);
|
|
expect(after!.name, 'Meena');
|
|
expect(after.mobile, '9840012345');
|
|
});
|
|
});
|
|
}
|
|
|
|
/// Accepts what it is told to and remembers what it saw.
|
|
class _RecordingTransport implements OrderTransport {
|
|
/// Heartbeats are irrelevant to what these tests assert; recorded only so the
|
|
/// fake satisfies the interface.
|
|
@override
|
|
Future<void> publishHealth(String payload) async {
|
|
healthBeats.add(payload);
|
|
}
|
|
|
|
final List<String> healthBeats = [];
|
|
|
|
_RecordingTransport({List<String> Function(List<String> ids)? accept})
|
|
: accept = accept ?? ((ids) => ids);
|
|
|
|
final List<String> Function(List<String> ids) accept;
|
|
|
|
final sentIds = <String>[];
|
|
int calls = 0;
|
|
|
|
@override
|
|
String get label => 'Recording';
|
|
|
|
@override
|
|
bool get isConnected => true;
|
|
|
|
@override
|
|
Stream<DownlinkMessage> get downlink => const Stream.empty();
|
|
|
|
@override
|
|
Stream<bool> get connectionState => const Stream.empty();
|
|
|
|
@override
|
|
Future<void> connect() async {}
|
|
|
|
@override
|
|
Future<PushReceipt> pushOrders(List<Map<String, Object?>> orders) async =>
|
|
PushReceipt(accepted: orders.map((o) => o['id']! as String).toList());
|
|
|
|
@override
|
|
Future<PushReceipt> pushCustomers(
|
|
List<Map<String, Object?>> customers,
|
|
) async {
|
|
calls++;
|
|
final ids = customers.map((c) => c['id']! as String).toList();
|
|
sentIds.addAll(ids);
|
|
return PushReceipt(accepted: accept(ids));
|
|
}
|
|
|
|
@override
|
|
Future<void> dispose() async {}
|
|
}
|
|
|
|
class _FailingTransport implements OrderTransport {
|
|
/// Heartbeats are irrelevant to what these tests assert; recorded only so the
|
|
/// fake satisfies the interface.
|
|
@override
|
|
Future<void> publishHealth(String payload) async {
|
|
healthBeats.add(payload);
|
|
}
|
|
|
|
final List<String> healthBeats = [];
|
|
|
|
@override
|
|
String get label => 'Failing';
|
|
|
|
@override
|
|
bool get isConnected => false;
|
|
|
|
@override
|
|
Stream<DownlinkMessage> get downlink => const Stream.empty();
|
|
|
|
@override
|
|
Stream<bool> get connectionState => const Stream.empty();
|
|
|
|
@override
|
|
Future<void> connect() async {}
|
|
|
|
@override
|
|
Future<PushReceipt> pushOrders(List<Map<String, Object?>> orders) async =>
|
|
throw const TransportException('unreachable');
|
|
|
|
@override
|
|
Future<PushReceipt> pushCustomers(
|
|
List<Map<String, Object?>> customers,
|
|
) async =>
|
|
throw const TransportException('unreachable');
|
|
|
|
@override
|
|
Future<void> dispose() async {}
|
|
}
|