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; 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; 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; 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 unsyncedCustomerCount() async => 0; @override Future> orderSyncRows({int limit = 200}) async => const []; @override Future 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 beats = []; @override bool get isConnected => connected; @override Future 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 publishHealth(String payload) async => throw Exception('the back office is down'); @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); }