diff --git a/lib/data/remote/http_order_transport.dart b/lib/data/remote/http_order_transport.dart index 9b8e41a..766080d 100644 --- a/lib/data/remote/http_order_transport.dart +++ b/lib/data/remote/http_order_transport.dart @@ -66,6 +66,40 @@ class HttpOrderTransport implements OrderTransport { Future pushCustomers(List> customers) => _post(path: 'customers', key: 'customers', items: customers); + /// One heartbeat, posted to the back office. + /// + /// Nothing is read back and nothing is retried. A heartbeat is only true for + /// the thirty seconds until the next one, so a failed beat is already stale + /// by the time a retry could land — the correct response is to let the board + /// go blank and say so with the next one. + /// + /// Every failure is swallowed for the reason the whole reporter swallows + /// them: a till that cannot say how it is must still sell. Stopping a shop + /// because a dashboard was unreachable would be a self-inflicted outage. + @override + Future publishHealth(String payload) async { + if (config.httpBaseUrl.isEmpty) return; + + try { + await _client + .post( + Uri.parse('${config.httpBaseUrl}/health'), + headers: { + 'content-type': 'application/json', + if (config.apiKey != null) + 'authorization': 'Bearer ${config.apiKey}', + }, + body: payload, + ) + // Deliberately shorter than ackTimeout. A bill is worth waiting + // twenty seconds for; a heartbeat that takes that long would still + // be in flight when the next one is due. + .timeout(const Duration(seconds: 5)); + } on Object { + // See above. + } + } + Future _post({ required String path, required String key, diff --git a/lib/data/remote/mqtt_order_transport.dart b/lib/data/remote/mqtt_order_transport.dart index 1be9935..f0a2833 100644 --- a/lib/data/remote/mqtt_order_transport.dart +++ b/lib/data/remote/mqtt_order_transport.dart @@ -265,6 +265,7 @@ class MqttOrderTransport implements OrderTransport { /// survive on the broker after the till was unplugged and keep it looking /// alive until something happened to overwrite it — which is exactly the /// failure a health board exists to catch. + @override Future publishHealth(String payload) async { if (!isConnected) return; _publish(config.healthTopic, payload); diff --git a/lib/data/remote/order_transport.dart b/lib/data/remote/order_transport.dart index 47dbc63..4cc4460 100644 --- a/lib/data/remote/order_transport.dart +++ b/lib/data/remote/order_transport.dart @@ -106,5 +106,18 @@ abstract class OrderTransport { bool get isConnected; + /// Sends one heartbeat, and never throws. + /// + /// On the interface rather than on the broker transport alone, because it was + /// on the broker transport alone and that was the bug: the reporter was + /// started behind an `is MqttOrderTransport` check, so a shop on the HTTP + /// route uploaded every bill correctly and never once appeared on the fleet + /// board. Nothing logged it, because nothing had gone wrong — the feature + /// simply did not exist on that route. + /// + /// A transport with nowhere to send it does nothing. That is a real answer, + /// not a stub: the simulated route has no back office to tell. + Future publishHealth(String payload); + Future dispose(); } diff --git a/lib/data/remote/simulated_order_transport.dart b/lib/data/remote/simulated_order_transport.dart index 1a95053..ea5a49a 100644 --- a/lib/data/remote/simulated_order_transport.dart +++ b/lib/data/remote/simulated_order_transport.dart @@ -32,6 +32,11 @@ class SimulatedOrderTransport implements OrderTransport { @override Future connect() async {} + /// Nowhere to send it. A fresh install has no back office configured, and + /// inventing a destination would only hide that. + @override + Future publishHealth(String payload) async {} + @override Future pushOrders(List> orders) => _accept(orders, 'bill'); diff --git a/lib/data/sync/health_reporter.dart b/lib/data/sync/health_reporter.dart index f4891b3..9a9b4b2 100644 --- a/lib/data/sync/health_reporter.dart +++ b/lib/data/sync/health_reporter.dart @@ -5,7 +5,7 @@ import 'dart:io'; import '../../core/config/sync_config.dart'; import '../../domain/repositories/sync_repository.dart'; import '../local/terminal_identity.dart'; -import '../remote/mqtt_order_transport.dart'; +import '../remote/order_transport.dart'; import 'sync_engine.dart'; /// What a till reports about itself, every 30 seconds. @@ -26,7 +26,7 @@ import 'sync_engine.dart'; /// till looking alive until something overwrote it. class HealthReporter { HealthReporter({ - required MqttOrderTransport transport, + required OrderTransport transport, required TerminalIdentity terminal, required SyncConfig config, required SyncEngine engine, @@ -46,7 +46,7 @@ class HealthReporter { _now = clock ?? DateTime.now, _schedule = scheduleTimer ?? Timer.new; - final MqttOrderTransport _transport; + final OrderTransport _transport; final TerminalIdentity _terminal; final SyncConfig _config; final SyncEngine _engine; diff --git a/lib/presentation/sync/providers/sync_controller.dart b/lib/presentation/sync/providers/sync_controller.dart index 4029777..adcd90f 100644 --- a/lib/presentation/sync/providers/sync_controller.dart +++ b/lib/presentation/sync/providers/sync_controller.dart @@ -239,7 +239,9 @@ final syncBootstrapProvider = FutureProvider((ref) async { await engine.start(); - // Fleet presence only exists on a transport that can carry it. + // The retained presence record needs a Last Will to pair with, so it genuinely + // only exists on the broker. The heartbeat below does not, and is started for + // every route. final transport = ref.read(orderTransportProvider); if (transport is MqttOrderTransport) { final reporter = PresenceReporter( @@ -253,12 +255,19 @@ final syncBootstrapProvider = FutureProvider((ref) async { ); ref.onDispose(reporter.dispose); await reporter.start(); + } - // The 30-second heartbeat the head-office board reads. Separate from the - // retained presence record above: that one is paired with the Last Will and - // answers "is this till alive", while this carries queue depth, today's - // trading and hardware state — what tells a till that is merely quiet from - // one that has stopped uploading. + // The 30-second heartbeat the head-office board reads. Separate from the + // retained presence record above: that one is paired with the Last Will and + // answers "is this till alive", while this carries queue depth, today's + // trading and hardware state — what tells a till that is merely quiet from + // one that has stopped uploading. + // + // Outside the MQTT check on purpose. It used to be inside, which meant a shop + // on the HTTP route uploaded every bill correctly and never appeared on the + // board at all — with nothing logged, because nothing had failed. Both routes + // can carry a heartbeat now, and the transport decides how. + { final health = HealthReporter( transport: transport, terminal: ref.read(terminalIdentityProvider), diff --git a/test/unit/customer_sync_test.dart b/test/unit/customer_sync_test.dart index 39dcefc..5b6cb9e 100644 --- a/test/unit/customer_sync_test.dart +++ b/test/unit/customer_sync_test.dart @@ -235,6 +235,15 @@ void main() { /// 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 publishHealth(String payload) async { + healthBeats.add(payload); + } + + final List healthBeats = []; + _RecordingTransport({List Function(List ids)? accept}) : accept = accept ?? ((ids) => ids); @@ -277,6 +286,15 @@ class _RecordingTransport implements OrderTransport { } class _FailingTransport implements OrderTransport { + /// Heartbeats are irrelevant to what these tests assert; recorded only so the + /// fake satisfies the interface. + @override + Future publishHealth(String payload) async { + healthBeats.add(payload); + } + + final List healthBeats = []; + @override String get label => 'Failing'; diff --git a/test/unit/health_reporter_test.dart b/test/unit/health_reporter_test.dart new file mode 100644 index 0000000..46d749a --- /dev/null +++ b/test/unit/health_reporter_test.dart @@ -0,0 +1,194 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:nearle_pos/core/config/sync_config.dart'; +import 'package:nearle_pos/data/local/terminal_identity.dart'; +import 'package:nearle_pos/data/remote/order_transport.dart'; +import 'package:nearle_pos/data/sync/health_reporter.dart'; +import 'package:nearle_pos/data/sync/sync_engine.dart'; +import 'package:nearle_pos/domain/entities/shift_report.dart'; +import 'package:nearle_pos/domain/repositories/sync_repository.dart'; + +/// The failure these cover reached production and stayed invisible for a day. +/// +/// The reporter was typed against the broker transport and started behind an +/// `is MqttOrderTransport` check, so a shop configured for the HTTP route +/// uploaded 17 bills perfectly and never once appeared on the fleet board. +/// Nothing logged it, because from the terminal's point of view nothing had +/// failed — the feature simply did not exist on that route. +/// +/// There was no test here at all. That is why it shipped. +void main() { + group('a heartbeat is sent on any route that can carry one', () { + test('publishes over a transport that is not the broker', () async { + final transport = _RecordingTransport(); + final reporter = _reporter(transport); + + await reporter.publish(); + + expect( + transport.beats, + hasLength(1), + reason: 'an HTTP terminal must still report its health', + ); + + final beat = jsonDecode(transport.beats.single) as Map; + expect(beat['terminal_id'], 'T5EDD'); + expect(beat['location_id'], '1135'); + expect(beat['status'], 'online'); + + reporter.dispose(); + }); + + test('carries the queue depth that makes a silent failure visible', + () async { + // The number the board exists for. A till that is connected, selling and + // quietly accumulating unsent bills looks completely healthy from the + // shop floor. + final transport = _RecordingTransport(); + final reporter = _reporter(transport, pending: 213); + + await reporter.publish(); + final beat = jsonDecode(transport.beats.single) as Map; + + expect(beat['pending_bills'], 213); + expect(beat['today_bills'], 17); + expect(beat['today_amount'], 2510.0); + + reporter.dispose(); + }); + + test('names the route it is using', () async { + // So a support call can tell an HTTP shop from a broker one without + // asking anybody to read a settings screen aloud. + final transport = _RecordingTransport(); + final reporter = _reporter(transport, transportKind: TransportKind.http); + + await reporter.publish(); + final beat = jsonDecode(transport.beats.single) as Map; + + expect(beat['transport'], 'http'); + + reporter.dispose(); + }); + + test('says nothing while the route is down', () async { + // Not an error — there is nowhere to send it. The board ages the till off + // by itself when the beats stop. + final transport = _RecordingTransport(connected: false); + final reporter = _reporter(transport); + + await reporter.publish(); + + expect(transport.beats, isEmpty); + reporter.dispose(); + }); + + test('a transport that throws does not stop the till', () async { + // The whole reason every failure here is swallowed. Halting a shop + // because a dashboard was unreachable would be a self-inflicted outage. + final transport = _ThrowingTransport(); + final reporter = _reporter(transport); + + await expectLater(reporter.publish(), completes); + reporter.dispose(); + }); + }); +} + +HealthReporter _reporter( + OrderTransport transport, { + int pending = 0, + TransportKind transportKind = TransportKind.http, +}) => + HealthReporter( + transport: transport, + terminal: const TerminalIdentity( + deviceId: 'device-1', + code: 'T5EDD', + name: 'Counter 1', + storeId: '1135', + ), + config: SyncConfig( + transport: transportKind, + storeId: '1135', + terminalId: 'T5EDD', + httpBaseUrl: 'https://example.invalid/api/v1/pos', + ), + engine: _StubEngine(pending: pending), + repository: _StubRepository(), + appVersion: '1.1.0', + ); + +/// A [SyncEngine] whose state the test dictates. +class _StubEngine implements SyncEngine { + _StubEngine({required this.pending}); + + final int pending; + + @override + SyncEngineState get state => SyncEngineState(pending: pending); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _StubRepository implements SyncRepository { + @override + Future unsyncedCustomerCount() async => 0; + + @override + Future> orderSyncRows({int limit = 200}) async => const []; + + @override + Future todayReport({ + required String terminalId, + required String cashierName, + bool scopeToCashier = false, + }) async => + ShiftReport( + businessDate: DateTime(2026, 8, 5), + terminalId: terminalId, + cashierName: cashierName, + billCount: 17, + itemCount: 21, + grossSales: 2510, + taxCollected: 265.06, + discountGiven: 0, + roundOff: 0, + paymentBreakdown: const {}, + loyaltyPointsIssued: 0, + loyaltyPointsRedeemed: 0, + ); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _RecordingTransport implements OrderTransport { + _RecordingTransport({this.connected = true}); + + final bool connected; + final List beats = []; + + @override + bool get isConnected => connected; + + @override + Future publishHealth(String payload) async => beats.add(payload); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _ThrowingTransport implements OrderTransport { + @override + bool get isConnected => true; + + @override + Future publishHealth(String payload) async => + throw Exception('the back office is down'); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/test/unit/retention_test.dart b/test/unit/retention_test.dart index 57643a2..2c0de9e 100644 --- a/test/unit/retention_test.dart +++ b/test/unit/retention_test.dart @@ -16,6 +16,15 @@ import 'package:nearle_pos/domain/usecases/checkout_sale.dart'; /// A transport whose answer each call is dictated by the test. class _ScriptedTransport implements OrderTransport { + /// Heartbeats are irrelevant to what these tests assert; recorded only so the + /// fake satisfies the interface. + @override + Future publishHealth(String payload) async { + healthBeats.add(payload); + } + + final List healthBeats = []; + _ScriptedTransport(this.answer); /// Given the ids in a batch, returns what the back office says about them. diff --git a/test/widget_test.dart b/test/widget_test.dart deleted file mode 100644 index d76d2a7..0000000 --- a/test/widget_test.dart +++ /dev/null @@ -1,30 +0,0 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:nearle_pos/main.dart'; - -void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); - - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); - - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); - }); -}