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:
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);
|
||||
}
|
||||
Reference in New Issue
Block a user