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

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