Files
nearle_pos/test/widget/app_smoke_test.dart
Suriya 0a49323858 Drain bills to the back office automatically, over MQTT or HTTP
Turns the orders table into a queue that empties itself. Bills were only
uploaded when a cashier pressed Sync at end of day; a till that was never
pressed held a day's takings indefinitely.

Drain engine (lib/data/sync/sync_engine.dart)
- Triggers on sale committed, network regained, 5-minute poll, head-office
  request, and the manual button.
- Single flight: a busy till firing a trigger per sale would otherwise have
  several passes reading the same pending rows and send every bill twice.
  A trigger arriving mid-drain is queued and replayed, so nothing is dropped.
- Exponential backoff with +/-20% jitter to a 5-minute ceiling. The jitter
  matters: a store's terminals all fail at the same instant when the line
  drops, and would retry in lockstep without it.
- Halts rather than loops on a failure retrying cannot fix (bad credential,
  refused batch). Pressing Sync clears the halt.

Transports (lib/data/remote/)
- OrderTransport interface; MQTT, HTTP and simulated implementations. The
  repository does not know which is in use.
- MQTT: QoS 1 uplink, application-level ACK correlated by batch_id on a return
  topic, retained Last Will for terminal-offline detection, downlink for
  catalogue pushes and remote sync requests.
- A broker PUBACK is never treated as acceptance. It means the broker holds
  the bytes, not that the ledger took the sale. Only ids the back office names
  are marked synced; silence leaves a bill pending.
- HTTP carries a stable idempotency key across retries of the same bills.

Retention
- Accepted bills are kept 7 days instead of deleted, so a batch the back
  office later loses can be re-sent in full. Purged after that; archived
  totals stay forever.
- forBusinessDate now reads pending rows only. A retained bill exists in both
  the orders table and day_archive, and summing both would overstate the day.

Fixes found while building this
- SyncEngine._refreshPending wrote state.copyWith(pending: await ...). Dart
  evaluates the receiver before the awaited argument, so a connectivity drop
  during the wait was silently overwritten by the stale snapshot. Caught by
  the first run of the new engine tests.
- PrinterSettingsController wrote state after four awaits with no mounted
  check, throwing "used after dispose" when Settings was left mid-load. This
  was pre-existing and reached the cashier as a red screen.

Also
- Header pill now reports real sync state: LIVE / n QUEUED / SYNCING /
  SYNC HALTED, with an explanation of where the bills are.
- Settings shows the route, last upload, next retry and retention window.
- docs/sync-contract.md states what the back office must implement, including
  the idempotency requirement that at-least-once delivery makes mandatory.

Tests: 90 -> 129 passing. New coverage for backoff shape and jitter band,
single flight, halting, ACK correlation and partial acceptance, at-least-once
duplicate handling, retention and purge, and no double-counting after a sync.
Suite run six times clean.

Not addressed: bills already synced by an older build went up overstated and
still need server-side reconciliation. Broker credentials have no Settings
editor yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 10:57:29 +05:30

136 lines
4.9 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:nearle_pos/app/app.dart';
import 'package:nearle_pos/data/datasources/local_store.dart';
import 'package:nearle_pos/data/datasources/seed_data.dart';
import 'package:nearle_pos/domain/entities/shift_report.dart';
import 'package:nearle_pos/presentation/auth/providers/auth_controller.dart';
import 'package:nearle_pos/presentation/pos/providers/cart_controller.dart';
import 'package:nearle_pos/presentation/pos/screens/pos_dashboard_screen.dart';
import 'package:nearle_pos/presentation/sync/providers/sync_controller.dart';
/// Boots the real application widget.
///
/// The unit suite proves the money is right; this proves the thing actually
/// assembles — router guard, provider graph, theme and shell — which type
/// checking alone cannot tell you.
void main() {
setUpAll(() {
// Tests have no network, and an attempted font fetch throws.
GoogleFonts.config.allowRuntimeFetching = false;
LocalStore.registerSeed(
products: SeedData.products,
customers: SeedData.customers,
);
});
setUp(() async {
await LocalStore.instance.reset(withCatalogue: true);
});
ShiftReport blankReport() => ShiftReport.blank(
businessDate: DateTime(2026, 7, 31),
terminalId: 'TERM-01',
cashierName: 'Suriya',
);
Future<void> bootApp(WidgetTester tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
// The background drain would open a broker connection and hit the
// disk on a clock this test controls. Neither is what these tests
// measure, and a half-driven timer would leak into the next one.
syncBootstrapProvider.overrideWith((ref) async {}),
// Catalogue reads come from the in-memory cache and resolve on the
// spot, but these four go to SQLite. Real disk I/O cannot be driven
// by the fake clock a widget test runs on: sqflite's own lock-warning
// timer is left pending and trips the binding's leak check. Stubbed
// so this test measures rendering, which is what it is for.
unsyncedCountProvider.overrideWith((ref) async => 0),
parkedBillsProvider.overrideWith((ref) async => []),
orderSyncRowsProvider.overrideWith((ref) async => []),
todayReportProvider.overrideWith((ref) async => blankReport()),
myShiftReportProvider.overrideWith((ref) async => blankReport()),
],
child: const NearlePosApp(),
),
);
await tester.pumpAndSettle();
}
/// Pumps a fixed number of frames instead of settling.
///
/// Once past login the header subscribes to a periodic clock, so there is
/// always another frame pending and `pumpAndSettle` never returns.
Future<void> settle(WidgetTester tester, {int frames = 15}) async {
for (var i = 0; i < frames; i++) {
await tester.pump(const Duration(milliseconds: 100));
}
}
Future<void> signIn(WidgetTester tester) async {
final fields = find.byType(TextFormField);
await tester.enterText(fields.first, DemoCredentials.email);
await tester.enterText(fields.at(1), DemoCredentials.password);
await tester.pump();
await tester.tap(find.text('Sign in').last);
await settle(tester);
}
testWidgets('the terminal starts on the login screen', (tester) async {
await bootApp(tester);
expect(find.byType(TextFormField), findsWidgets);
expect(find.text('Sign in'), findsWidgets);
expect(tester.takeException(), isNull);
});
testWidgets('the auth guard keeps an unauthenticated terminal out',
(tester) async {
await bootApp(tester);
expect(find.byType(PosDashboardScreen), findsNothing);
});
testWidgets('signing in reaches the billing terminal', (tester) async {
await bootApp(tester);
await signIn(tester);
expect(find.byType(PosDashboardScreen), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('the shell renders every module without throwing',
(tester) async {
// Wide enough that the sidebar shows labels and the bill stays docked —
// the layout where a button label and its total compete for width.
tester.view.physicalSize = const Size(1800, 1200);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await bootApp(tester);
await signIn(tester);
for (final label in [
'Customers',
'Product Import',
'Promo',
'Events',
'Settings',
'POS',
]) {
final target = find.text(label);
expect(target, findsWidgets, reason: 'no sidebar entry for "$label"');
await tester.tap(target.first);
await settle(tester);
expect(tester.takeException(), isNull, reason: 'opening "$label" threw');
}
});
}