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:
Suriya
2026-08-05 18:28:22 +05:30
parent 6c0266c9c7
commit 353c6c1075
10 changed files with 292 additions and 39 deletions

View File

@@ -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,

View File

@@ -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);

View File

@@ -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();
}

View File

@@ -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');