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