Files
nearle_pos/test/unit/persistence_test.dart
Suriya fbfc02d140 Pull the catalogue from a real endpoint, with delta sync
Replaces the last simulation on the inbound side. RemoteCatalogueSource
returned SeedData after a fake progress bar; there was no wire format, no
endpoint, and no way to receive an update short of reinstalling.

Wire format (data/remote/catalogue_wire.dart)
- Tolerant where it should be: a catalogue of 4,000 products must not fail to
  import over one absent emoji, so optional fields take defaults and an
  unrecognised category files under Grocery — the item still scans, prices and
  bills.
- Strict where it matters: no id, name, barcode or price and the import fails.
  A silently dropped product is a shelf item that scans to nothing, discovered
  with a queue waiting.
- GST accepts 18 or 0.18 and reads both the same. Back offices disagree about
  which they mean, and getting it wrong silently changes the tax on every line.

HTTP source with paging and deltas
- GET {base}/catalogue?since={revision}&page={n}. Paged because a supermarket
  catalogue is tens of thousands of rows: one response times out on a shop's
  line and stalls the UI decoding it. Capped at 200 pages so a bad deployment
  cannot become an infinite request loop against a shop's connection.
- `since` carries the revision already held, so a normal morning fetches a
  handful of price changes rather than the whole book. A server that cannot do
  deltas ignores it and answers is_delta:false — the terminal reads the flag
  rather than assuming, so both work.
- A bad credential is non-retryable and says so, leaving the working catalogue
  in place so billing continues.

Applying deltas without losing local state
- A full snapshot withdraws what it omits; a delta must not. Read as a
  snapshot, the first morning price change would empty the shelf.
- Retired products are marked inactive, not deleted — order lines already
  recorded point at them, and a hard delete would orphan a bill's history.
- Locally registered shoppers survive a pull, as before.
- The unsynced-stock replay is now scoped to the products the pull actually
  overwrote. It exists because a server count predates local sales; running it
  over a delta that never carried that product would subtract those units a
  second time and quietly empty a shelf that is full. Both halves of that rule
  are tested.

MQTT stays the nudge, not the transport: a catalogue push on
pos/{store}/catalogue makes every terminal pull immediately, but the rows come
over HTTP, because a broker is the wrong shape for tens of thousands of them.

Tests: 210 -> 234. docs/sync-contract.md now covers both directions, including
a field-by-field table of what happens when something is missing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 15:37:42 +05:30

474 lines
15 KiB
Dart

import 'package:flutter_test/flutter_test.dart';
import 'package:nearle_pos/data/datasources/local_store.dart';
import 'package:nearle_pos/data/remote/simulated_catalogue_source.dart';
import 'package:nearle_pos/data/datasources/seed_data.dart';
import 'package:nearle_pos/data/remote/simulated_order_transport.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/shift_report.dart';
import 'package:nearle_pos/domain/entities/sync_event.dart';
import 'package:nearle_pos/domain/entities/transaction.dart';
import 'package:nearle_pos/domain/usecases/checkout_sale.dart';
/// Guards the boundary between a live bill and a stored one.
///
/// Every figure a shop is paid on survives a write to SQLite and a read back
/// out of it. These are regressions: the read path used to rebuild a bill from
/// its lines alone, silently dropping bill-level discounts and loyalty.
void main() {
late LocalStore store;
late ProductRepositoryImpl products;
late CustomerRepositoryImpl customers;
late TransactionRepositoryImpl transactions;
late CheckoutSale checkout;
setUpAll(() {
LocalStore.registerSeed(
products: SeedData.products,
customers: SeedData.customers,
);
});
setUp(() async {
store = LocalStore.instance;
await store.reset(withCatalogue: true);
products = ProductRepositoryImpl(store);
customers = CustomerRepositoryImpl(store);
transactions = TransactionRepositoryImpl(store);
checkout = CheckoutSale(
productRepository: products,
customerRepository: customers,
transactionRepository: transactions,
);
});
/// A Gold member (5% tier discount) holding 400 points.
Future<Customer> goldMember() async {
final created = await customers.create(const Customer(
id: 'ignored',
name: 'Gold Shopper',
mobile: '9000000001',
),);
return customers.update(
created.copyWith(loyaltyPoints: 400, lifetimeSpend: 60000),
);
}
group('order round trip', () {
test('bill discount, loyalty and total survive a write and read back',
() async {
final milk = (await products.findByBarcode('8901234500011'))!;
final member = await goldMember();
// 10 x 62.00 = 620 subtotal, less 5% tier (31) and a flat 50 = 81,
// less 40 points at 0.25 = 10. Payable 529.
final cart = Cart(
lines: [CartLine(product: milk, quantity: 10)],
customer: member,
billDiscount: const Discount(type: DiscountType.flat, value: 50),
pointsRedeemed: 40,
);
expect(cart.grandTotal, 529);
expect(cart.billDiscountTotal, 81);
final result = await checkout(
cart: cart,
payments: const [
PaymentSplit(method: PaymentMethod.cash, amount: 529, tendered: 529),
],
cashierName: 'Divya',
terminalId: 'T0TEST',
);
final stored = (await transactions.history()).single;
expect(stored.total, result.transaction.total,
reason: 'the figure charged must not move in storage',);
expect(stored.total, 529);
expect(stored.cart.billDiscountTotal, 81);
expect(stored.cart.loyaltyRedemptionValue, 10);
expect(stored.cart.pointsRedeemed, 40);
expect(stored.cart.taxAmount, cart.taxAmount);
expect(stored.cart.subtotal, 620);
});
test('a rebuilt bill does not re-apply the tier discount', () async {
final milk = (await products.findByBarcode('8901234500011'))!;
final member = await goldMember();
final cart = Cart(
lines: [CartLine(product: milk, quantity: 10)],
customer: member,
);
// 620 less the 5% tier discount only.
expect(cart.grandTotal, 589);
await checkout(
cart: cart,
payments: const [
PaymentSplit(method: PaymentMethod.cash, amount: 589, tendered: 600),
],
cashierName: 'Divya',
terminalId: 'T0TEST',
);
final stored = (await transactions.history()).single;
expect(stored.cart.billDiscountTotal, 31);
expect(stored.total, 589);
});
test('the shift report reads back the amount actually charged', () async {
final milk = (await products.findByBarcode('8901234500011'))!;
final member = await goldMember();
await checkout(
cart: Cart(
lines: [CartLine(product: milk, quantity: 10)],
customer: member,
billDiscount: const Discount(type: DiscountType.flat, value: 50),
pointsRedeemed: 40,
),
payments: const [
PaymentSplit(method: PaymentMethod.cash, amount: 529, tendered: 529),
],
cashierName: 'Divya',
terminalId: 'T0TEST',
);
final report = ShiftReport.fromTransactions(
transactions: await transactions.history(),
businessDate: DateTime.now(),
terminalId: 'TERM-01',
cashierName: 'Divya',
);
expect(report.grossSales, 529);
expect(report.discountGiven, 81);
expect(report.loyaltyPointsRedeemed, 40);
});
test('the bill is stamped with the operator who rang it', () async {
final milk = (await products.findByBarcode('8901234500011'))!;
await checkout(
cart: Cart(lines: [CartLine(product: milk, quantity: 1)]),
payments: const [
PaymentSplit(method: PaymentMethod.cash, amount: 62, tendered: 100),
],
cashierName: 'Rahul',
terminalId: 'TERM-07',
);
final stored = (await transactions.history()).single;
expect(stored.cashierName, 'Rahul');
expect(stored.terminalId, 'TERM-07');
});
});
group('stock safety', () {
test('a resumed parked bill is re-checked against live stock', () async {
final milk = (await products.findByBarcode('8901234500011'))!;
await transactions.park(ParkedBill(
id: 'p-1',
cart: Cart(lines: [CartLine(product: milk, quantity: milk.stock)]),
parkedAt: DateTime.now(),
),);
// The same stock is sold on another bill in the meantime.
await products.decrementStock({milk.id: milk.stock});
final resumed = (await transactions.parkedBills()).single;
await expectLater(
checkout(
cart: resumed.cart,
payments: [
PaymentSplit(
method: PaymentMethod.cash,
amount: resumed.cart.grandTotal,
tendered: resumed.cart.grandTotal,
),
],
cashierName: 'Divya',
terminalId: 'T0TEST',
),
throwsA(isA<CheckoutFailure>()),
);
expect(await transactions.history(), isEmpty);
});
test('a re-import does not restore stock already sold', () async {
final milk = (await products.findByBarcode('8901234500011'))!;
final opening = milk.stock;
await checkout(
cart: Cart(lines: [CartLine(product: milk, quantity: 4)]),
payments: const [
PaymentSplit(method: PaymentMethod.cash, amount: 248, tendered: 250),
],
cashierName: 'Divya',
terminalId: 'T0TEST',
);
expect((await products.findByBarcode('8901234500011'))!.stock,
opening - 4,);
// The morning catalogue is pulled again before the bills went up.
await store.importCatalogue(
products: SeedData.products(),
customers: SeedData.customers(),
revision: 'seed-2',
at: DateTime.now(),
);
expect(
(await products.findByBarcode('8901234500011'))!.stock,
opening - 4,
reason: 'units sold on unsynced bills must not reappear on the shelf',
);
});
});
group('atomicity', () {
test('nothing is persisted when the sale cannot be completed', () async {
final milk = (await products.findByBarcode('8901234500011'))!;
final opening = milk.stock;
// A customer that was never written to the database.
const ghost = Customer(id: 'missing', name: 'Ghost', mobile: '9999999999');
await expectLater(
checkout(
cart: Cart(
lines: [CartLine(product: milk, quantity: 2)],
customer: ghost,
),
payments: const [
PaymentSplit(method: PaymentMethod.cash, amount: 124, tendered: 124),
],
cashierName: 'Divya',
terminalId: 'T0TEST',
),
throwsA(isA<CheckoutFailure>()),
);
expect(await transactions.history(), isEmpty,
reason: 'a failed sale must not leave a bill behind',);
expect((await products.findByBarcode('8901234500011'))!.stock, opening,
reason: 'a failed sale must not consume stock',);
});
test('concurrent checkouts never share an invoice sequence', () async {
final numbers = await Future.wait(
List.generate(25, (_) => transactions.nextInvoiceSequence()),
);
expect(numbers.toSet().length, numbers.length);
});
});
group('end of day', () {
test('the archived day totals match what was charged', () async {
final sync = SyncRepositoryImpl(
store,
SimulatedCatalogueSource(isOffline: () => false),
SimulatedOrderTransport(isOffline: () => false),
);
final milk = (await products.findByBarcode('8901234500011'))!;
final member = await goldMember();
await checkout(
cart: Cart(
lines: [CartLine(product: milk, quantity: 10)],
customer: member,
billDiscount: const Discount(type: DiscountType.flat, value: 50),
pointsRedeemed: 40,
),
payments: const [
PaymentSplit(method: PaymentMethod.cash, amount: 529, tendered: 529),
],
cashierName: 'Divya',
terminalId: 'T0TEST',
);
final outcome = await sync.syncOrders();
expect(outcome.uploaded, 1);
expect(await sync.unsyncedCount(), 0);
// Synced bills are deleted, so today's figures now come from the archive.
// A wrong row here is permanent — there is nothing left to recompute from.
final report = await sync.todayReport(
terminalId: 'TERM-01',
cashierName: 'Divya',
);
expect(report.billCount, 1);
expect(report.grossSales, 529);
expect(report.discountGiven, 81);
expect(report.loyaltyPointsRedeemed, 40);
expect(report.paymentBreakdown[PaymentMethod.cash], 529);
});
});
group('sync log', () {
test('survives a restart, because it outlives the bills it describes',
() async {
final sync = SyncRepositoryImpl(
store,
SimulatedCatalogueSource(isOffline: () => false),
SimulatedOrderTransport(isOffline: () => false),
);
final milk = (await products.findByBarcode('8901234500011'))!;
await checkout(
cart: Cart(lines: [CartLine(product: milk, quantity: 1)]),
payments: const [
PaymentSplit(method: PaymentMethod.cash, amount: 62, tendered: 62),
],
cashierName: 'Divya',
terminalId: 'T0TEST',
);
await sync.syncOrders();
expect(sync.events, isNotEmpty);
final summary = sync.events.first.summary;
// The cache is dropped and rebuilt from disk, as it is on a cold start.
await store.hydrate();
expect(sync.events, isNotEmpty,
reason: 'the only record that those bills went up must persist',);
expect(sync.events.first.summary, summary);
expect(sync.events.first.payload['invoices'], isNotEmpty);
});
test('a failed import is recorded with its error', () async {
final sync = SyncRepositoryImpl(
store,
SimulatedCatalogueSource(isOffline: () => true),
SimulatedOrderTransport(isOffline: () => true),
);
await sync.importCatalogue();
await store.hydrate();
expect(sync.events.first.status, SyncStatus.failed);
expect(sync.events.first.error, isNotNull);
});
});
group('shift report scoping', () {
Future<void> sell(String cashier, double qty) async {
final milk = (await products.findByBarcode('8901234500011'))!;
final due = Cart(lines: [CartLine(product: milk, quantity: qty)]).grandTotal;
await checkout(
cart: Cart(lines: [CartLine(product: milk, quantity: qty)]),
payments: [
PaymentSplit(method: PaymentMethod.cash, amount: due, tendered: due),
],
cashierName: cashier,
terminalId: 'T0TEST',
);
}
test('a cashier settles their own till, not the terminal', () async {
final sync = SyncRepositoryImpl(
store,
SimulatedCatalogueSource(isOffline: () => false),
SimulatedOrderTransport(isOffline: () => false),
);
await sell('Divya', 2); // 124
await sell('Rahul', 3); // 186
final divya = await sync.todayReport(
terminalId: 'TERM-01',
cashierName: 'Divya',
scopeToCashier: true,
);
final terminal = await sync.todayReport(
terminalId: 'TERM-01',
cashierName: 'Divya',
);
expect(divya.billCount, 1);
expect(divya.grossSales, 124);
expect(terminal.billCount, 2);
expect(terminal.grossSales, 310);
});
test('scoping holds after the bills are uploaded and deleted', () async {
final sync = SyncRepositoryImpl(
store,
SimulatedCatalogueSource(isOffline: () => false),
SimulatedOrderTransport(isOffline: () => false),
);
await sell('Divya', 2);
await sell('Rahul', 3);
await sync.syncOrders();
expect(await sync.unsyncedCount(), 0);
final divya = await sync.todayReport(
terminalId: 'TERM-01',
cashierName: 'Divya',
scopeToCashier: true,
);
final terminal = await sync.todayReport(
terminalId: 'TERM-01',
cashierName: 'Divya',
);
expect(divya.grossSales, 124,
reason: 'the archive must keep each cashier separable',);
expect(divya.billCount, 1);
expect(terminal.grossSales, 310);
expect(terminal.billCount, 2);
});
});
group('customer lookup', () {
test('a mobile number matches however the cashier punctuates it', () async {
await customers.create(const Customer(
id: 'ignored',
name: 'Punctuation Test',
mobile: '9000000042',
),);
for (final query in ['9000000042', '90000 00042', '90-000-00042']) {
expect(
(await customers.search(query)).map((c) => c.name),
contains('Punctuation Test'),
reason: 'searching "$query" should find the customer',
);
}
});
});
group('tax invoice', () {
test('the slab breakdown sums to the GST charged', () async {
// Two different slabs plus a bill discount, so apportioning is in play.
final milk = (await products.findByBarcode('8901234500011'))!; // 5%
final all = await products.getAll();
final eighteen = all.firstWhere((p) => p.gstRate == 0.18);
final cart = Cart(
lines: [
CartLine(product: milk, quantity: 3),
CartLine(product: eighteen, quantity: 7),
],
billDiscount: const Discount(type: DiscountType.percentage, value: 13),
);
final summed = cart.taxBreakdown.values.fold(0.0, (a, b) => a + b);
expect(summed, closeTo(cart.taxAmount, 0.0001));
});
});
}