Report health on every transport, not only the broker
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>
This commit is contained in:
@@ -66,6 +66,40 @@ class HttpOrderTransport implements OrderTransport {
|
||||
Future<PushReceipt> pushCustomers(List<Map<String, Object?>> 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<void> 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<PushReceipt> _post({
|
||||
required String path,
|
||||
required String key,
|
||||
|
||||
@@ -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<void> publishHealth(String payload) async {
|
||||
if (!isConnected) return;
|
||||
_publish(config.healthTopic, payload);
|
||||
|
||||
@@ -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<void> publishHealth(String payload);
|
||||
|
||||
Future<void> dispose();
|
||||
}
|
||||
|
||||
@@ -32,6 +32,11 @@ class SimulatedOrderTransport implements OrderTransport {
|
||||
@override
|
||||
Future<void> connect() async {}
|
||||
|
||||
/// Nowhere to send it. A fresh install has no back office configured, and
|
||||
/// inventing a destination would only hide that.
|
||||
@override
|
||||
Future<void> publishHealth(String payload) async {}
|
||||
|
||||
@override
|
||||
Future<PushReceipt> pushOrders(List<Map<String, Object?>> orders) =>
|
||||
_accept(orders, 'bill');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -239,7 +239,9 @@ final syncBootstrapProvider = FutureProvider<void>((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<void>((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.
|
||||
//
|
||||
// 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),
|
||||
|
||||
@@ -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<void> publishHealth(String payload) async {
|
||||
healthBeats.add(payload);
|
||||
}
|
||||
|
||||
final List<String> healthBeats = [];
|
||||
|
||||
_RecordingTransport({List<String> Function(List<String> 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<void> publishHealth(String payload) async {
|
||||
healthBeats.add(payload);
|
||||
}
|
||||
|
||||
final List<String> healthBeats = [];
|
||||
|
||||
@override
|
||||
String get label => 'Failing';
|
||||
|
||||
|
||||
194
test/unit/health_reporter_test.dart
Normal file
194
test/unit/health_reporter_test.dart
Normal file
@@ -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<String, Object?>;
|
||||
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<String, Object?>;
|
||||
|
||||
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<String, Object?>;
|
||||
|
||||
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<int> unsyncedCustomerCount() async => 0;
|
||||
|
||||
@override
|
||||
Future<List<OrderSyncRow>> orderSyncRows({int limit = 200}) async => const [];
|
||||
|
||||
@override
|
||||
Future<ShiftReport> 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<String> beats = [];
|
||||
|
||||
@override
|
||||
bool get isConnected => connected;
|
||||
|
||||
@override
|
||||
Future<void> 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<void> publishHealth(String payload) async =>
|
||||
throw Exception('the back office is down');
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
@@ -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<void> publishHealth(String payload) async {
|
||||
healthBeats.add(payload);
|
||||
}
|
||||
|
||||
final List<String> healthBeats = [];
|
||||
|
||||
_ScriptedTransport(this.answer);
|
||||
|
||||
/// Given the ids in a batch, returns what the back office says about them.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user