import 'package:flutter_test/flutter_test.dart'; import 'package:geolocator/geolocator.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:miler/controllers/pickups_controller.dart'; import 'package:miler/data/geofence.dart'; import 'package:miler/data/miler_api.dart'; /// ───────────────────────────────────────────────────────────────────────── /// THE PROXIMITY FENCE /// /// This is the control that decides whether "Picked up" means the rider was /// standing at the door or merely pressed a button, and it has been wrong in /// four directions inside one year: compiled off by default, set to a 10 m /// radius no consumer GPS can resolve, failing **open** on any thrown /// exception, and crediting the phone's own error to the rider so that a vague /// fix bought slack rather than a refusal. /// /// So the tests below are not a spot check of the arithmetic. They pin, in /// order: /// /// 1. the **switch** — on by default, because that default has flipped twice; /// 2. the **radius** — 100 m, inclusive at the boundary; /// 3. the **distance table** the spec asks for, 0 m to 250 m; /// 4. every way of **failing to measure**, each of which must refuse; /// 5. the one that matters most — **a blocked rung sends no HTTP request.** /// /// (5) is the whole point. A fence that shows a message and posts the status /// anyway is worse than no fence: the hub is told the rider was there *and* /// the app claims to have checked. /// ───────────────────────────────────────────────────────────────────────── /// A stop in Coimbatore. Any point would do; a real one keeps the numbers /// recognisable when a failure prints them. const double kTargetLat = 11.0272; const double kTargetLng = 76.9905; /// Metres per degree of latitude. Constant enough for a fixture — the tests /// assert against what [Geolocator.distanceBetween] actually measures, not /// against this, so the approximation cannot make a test pass wrongly. const double _metresPerDegreeLat = 111320.0; /// A rider fix [metres] due north of the stop. Position riderAt( double metres, { double accuracy = 8, Duration age = Duration.zero, }) => Position( latitude: kTargetLat + (metres / _metresPerDegreeLat), longitude: kTargetLng, timestamp: DateTime.now().subtract(age), accuracy: accuracy, altitude: 0, altitudeAccuracy: 0, heading: 0, headingAccuracy: 0, speed: 0, speedAccuracy: 0, ); void useFix(Position p) => Geofence.positionProvider = () async => GeofenceFix(position: p); void useFailure(GeofenceOutcome outcome) => Geofence.positionProvider = () async => GeofenceFix(failure: outcome); void main() { tearDown(Geofence.useDevice); // ─────────────────────────────────────────────────────────────────────── group('the switch and the radius', () { test('enforcement is ON by default', () { // It was `defaultValue: false`, so every release build shipped with the // fence returning true on its third line. If this fails, either the // default was reverted — which is the release blocker this file exists to // prevent — or the run passed `--dart-define=ENFORCE_GEOFENCE=false`, in // which case the failure is correct and is telling you so. expect( kGeofenceEnforced, isTrue, reason: 'Proximity enforcement must be ON by default. Either the default ' 'was reverted, or this run passed ENFORCE_GEOFENCE=false.', ); }); test('the radius is 100 metres, and there is only one of them', () { // The app once held three: a configured `pickupradius` defaulting to 100, // a hardcoded 500 in Home's bulk gate, and 10 in the controller. Which // fence a rider met depended on whether he ticked boxes or slid a sheet. expect(kGeofenceRadiusMetres, 100); }); test('one fix may serve a batch, but only briefly', () { // [kGeofenceFixReuse] is what makes a twenty-order bulk action cost one // GPS settle instead of twenty. It is a performance change to a safety // control, so the two bounds that keep it honest are asserted here. expect( kGeofenceFixReuse, lessThanOrEqualTo(kGeofenceFixMaxAge), reason: 'the reuse window must sit inside the staleness rule — a fix too ' 'old to measure with cannot become acceptable by being cached', ); // At walking pace (~1.4 m/s) the window is ~20 m of possible drift // against a 100 m fence. Riding away covers the radius inside it, so a // rider who genuinely leaves is refused on the next real fix rather than // carried to the next door by the cached one. expect( kGeofenceFixReuse.inSeconds * 1.4, lessThan(kGeofenceRadiusMetres / 2), reason: 'drift during the window must stay well under the radius', ); }); test('resetting the cache is available to sign-out and tests', () { // A cached position is a fact about a rider. It must not outlive him on a // shared handset. Geofence.resetCache(); Geofence.useDevice(); }); test('a cached fix has a shelf life, and a vague one is rejected', () { expect(kGeofenceFixMaxAge, const Duration(seconds: 30)); expect( kGeofenceMaxAccuracyMetres, lessThan(kGeofenceRadiusMetres), reason: 'a fix whose error is as large as the fence cannot resolve it, and ' 'must be refused rather than credited', ); }); }); // ─────────────────────────────────────────────────────────────────────── group('the distance table', () { // Every case the spec names. Each asserts the *rule* against what // Geolocator actually measured, so a fixture that drifts by a metre cannot // quietly invert a boundary case. for (final metres in const [0.0, 25.0, 50.0, 99.0, 100.0, 101.0, 125.0, 250.0]) { test('${metres.round()} m', () async { final pos = riderAt(metres); useFix(pos); final measured = Geofence.distanceBetween( kTargetLat, kTargetLng, pos.latitude, pos.longitude, ); expect( measured, closeTo(metres, 1.0), reason: 'fixture should sit where it claims', ); final d = await Geofence.check( targetLat: kTargetLat, targetLng: kTargetLng, action: 'Arrived', ); final shouldAllow = measured <= kGeofenceRadiusMetres; expect( d.allowed, shouldAllow, reason: 'at ${measured.toStringAsFixed(1)} m against a ' '${kGeofenceRadiusMetres.round()} m fence', ); expect( d.outcome, shouldAllow ? GeofenceOutcome.inside : GeofenceOutcome.outside, ); expect(d.distanceMetres, closeTo(measured, 0.001)); expect(d.accuracyMetres, 8); expect(d.timestamp, isNotNull); }); } test('the boundary is inclusive — 100 m is in, a hair over is out', () { // Stated as arithmetic rather than as a fixture, because this is the one // place a `<` for a `<=` changes a rider's day and no GPS fixture can be // placed accurately enough to catch it. expect(99.999 <= kGeofenceRadiusMetres, isTrue); expect(100.0 <= kGeofenceRadiusMetres, isTrue); expect(100.001 <= kGeofenceRadiusMetres, isFalse); }); test('a refusal tells the rider how far, and in a unit he uses', () async { useFix(riderAt(240)); final d = await Geofence.check( targetLat: kTargetLat, targetLng: kTargetLng, action: 'Arrived', ); expect(d.allowed, isFalse); expect(d.reason, contains('240 m')); expect(d.reason, contains('100 m')); expect(d.reason, contains('arrived')); // Never a developer error, a status code or a coordinate pair. expect(d.reason, isNot(contains('Exception'))); expect(d.reason, isNot(contains('null'))); }); test('over a kilometre reads in kilometres', () async { useFix(riderAt(4200)); final d = await Geofence.check( targetLat: kTargetLat, targetLng: kTargetLng, action: 'Delivered', ); expect(d.reason, contains('4.2 km')); expect(d.reason, contains('delivered')); }); test('the verb follows the rung', () async { useFix(riderAt(500)); for (final action in const [ 'Arrived', 'Picked', 'Picked up', 'Delivery arrived', 'Delivered', 'Handed over', ]) { final d = await Geofence.check( targetLat: kTargetLat, targetLng: kTargetLng, action: action, ); expect(d.allowed, isFalse, reason: action); expect(d.reason, contains(action.toLowerCase()), reason: action); } }); }); // ─────────────────────────────────────────────────────────────────────── group('every way of failing to measure is a refusal', () { // The old implementation allowed on four of these six. That is what made // the fence decorative: switching location off opened every rung. test('GPS switched off', () async { useFailure(GeofenceOutcome.serviceDisabled); final d = await Geofence.check( targetLat: kTargetLat, targetLng: kTargetLng, action: 'Arrived', ); expect(d.allowed, isFalse); expect(d.outcome, GeofenceOutcome.serviceDisabled); expect(d.reason, contains('Location is switched off')); }); test('permission denied', () async { useFailure(GeofenceOutcome.permissionDenied); final d = await Geofence.check( targetLat: kTargetLat, targetLng: kTargetLng, action: 'Delivered', ); expect(d.allowed, isFalse); expect(d.outcome, GeofenceOutcome.permissionDenied); expect(d.reason, contains('Allow location access')); }); test('permission denied forever points at Settings', () async { useFailure(GeofenceOutcome.permissionDeniedForever); final d = await Geofence.check( targetLat: kTargetLat, targetLng: kTargetLng, action: 'Arrived', ); expect(d.allowed, isFalse); expect(d.outcome, GeofenceOutcome.permissionDeniedForever); expect(d.reason, contains('Settings')); }); test('a timeout waiting for the fix', () async { useFailure(GeofenceOutcome.timeout); final d = await Geofence.check( targetLat: kTargetLat, targetLng: kTargetLng, action: 'Arrived', ); expect(d.allowed, isFalse); expect(d.outcome, GeofenceOutcome.timeout); }); test('a stale fix is not a measurement', () async { // On a round, a stale position is reliably the *previous* stop — which is // how a rider marks a delivery arrived from the last street. useFailure(GeofenceOutcome.staleFix); final d = await Geofence.check( targetLat: kTargetLat, targetLng: kTargetLng, action: 'Arrived', ); expect(d.allowed, isFalse); expect(d.outcome, GeofenceOutcome.staleFix); }); test('a fix too vague to resolve the fence is refused, not credited', () async { // The inversion this replaces: the old check was // `distance - accuracy > radius`, so a ±250 m fix bought 250 m of slack. // The worse the fix, the easier it was to pass. Here the rider is 10 m // away — comfortably inside — and is still refused, because the phone // cannot show that he is. useFix(riderAt(10, accuracy: kGeofenceMaxAccuracyMetres + 1)); final d = await Geofence.check( targetLat: kTargetLat, targetLng: kTargetLng, action: 'Arrived', ); expect(d.allowed, isFalse); expect(d.outcome, GeofenceOutcome.poorAccuracy); expect(d.reason, contains('Step into the open')); expect(d.accuracyMetres, kGeofenceMaxAccuracyMetres + 1); }); test('a vague fix cannot pass even when it is standing on the stop', () async { useFix(riderAt(0, accuracy: 500)); final d = await Geofence.check( targetLat: kTargetLat, targetLng: kTargetLng, action: 'Delivered', ); expect(d.allowed, isFalse, reason: 'distance 0 must not beat accuracy'); }); test('a fix at the accuracy ceiling is still usable', () async { useFix(riderAt(20, accuracy: kGeofenceMaxAccuracyMetres)); final d = await Geofence.check( targetLat: kTargetLat, targetLng: kTargetLng, action: 'Arrived', ); expect(d.allowed, isTrue, reason: 'the ceiling is inclusive'); }); test('the provider throwing is a refusal, not a pass', () async { // This is the `catch { return true; // fail-safe }` that used to open // every rung in the app on any throw from the location stack. Geofence.positionProvider = () async => throw Exception('platform'); final d = await Geofence.check( targetLat: kTargetLat, targetLng: kTargetLng, action: 'Arrived', ); expect(d.allowed, isFalse); expect(d.outcome, GeofenceOutcome.error); expect(d.reason, isNotNull); expect(d.reason, isNot(contains('platform')), reason: 'the rider must not be shown the exception'); }); test('a rider fix of 0,0 is absent, not the Gulf of Guinea', () async { useFix( Position( latitude: 0, longitude: 0, timestamp: DateTime.now(), accuracy: 5, altitude: 0, altitudeAccuracy: 0, heading: 0, headingAccuracy: 0, speed: 0, speedAccuracy: 0, ), ); final d = await Geofence.check( targetLat: kTargetLat, targetLng: kTargetLng, action: 'Arrived', ); expect(d.allowed, isFalse); expect(d.outcome, GeofenceOutcome.noFix); }); }); // ─────────────────────────────────────────────────────────────────────── group('a stop with no coordinates', () { // This used to return true — "Missing coordinates. Proceeding with update." // A booking with a blank latitude therefore opened every rung on that stop, // which is the bypass that makes a fence decorative. for (final pair in const [ (null, null, 'both null'), (0.0, 0.0, 'both zero'), (kTargetLat, 0.0, 'longitude zero'), (0.0, kTargetLng, 'latitude zero'), (95.0, kTargetLng, 'latitude out of range'), (kTargetLat, 200.0, 'longitude out of range'), ]) { test('is refused — ${pair.$3}', () async { useFix(riderAt(0)); final d = await Geofence.check( targetLat: pair.$1, targetLng: pair.$2, action: 'Arrived', ); expect(d.allowed, isFalse); expect(d.outcome, GeofenceOutcome.noTarget); // The rider cannot fix the hub's data from a doorstep, so the sentence // sends him to the people who can. expect(d.reason, contains('office')); }); } test('it does not even ask the phone for a fix', () async { // An 8-second GPS settle to answer a question with no target is time // taken off a rider's round for nothing. var asked = false; Geofence.positionProvider = () async { asked = true; return GeofenceFix(position: riderAt(0)); }; await Geofence.check( targetLat: null, targetLng: null, action: 'Arrived', ); expect(asked, isFalse); }); }); // ─────────────────────────────────────────────────────────────────────── group('BLOCKED MEANS NO REQUEST IS SENT', () { // The rule the whole control rests on. Every one of these drives the real // `PickupsController` rung with an HTTP client that fails the test if it is // ever called — so this asserts the *call sites*, not the policy object. late PickupsController controller; late List sent; setUp(() { SharedPreferences.setMockInitialValues({'userid': 38}); sent = []; MilerApi.client = MockClient((req) async { sent.add('${req.method} ${req.url.path}'); // A realistic `reached` reply, in the shape production actually // returns — `success` is the envelope key (`ApiConfig.toLegacyEnvelope` // reads it; `status` is ignored), and the arrival stamp sits inside // `data`. A bare 200 is deliberately NOT read as a transition (see // `MilerLifecycle.reached`), so a thin fixture would fail the positive // case below for a reason that has nothing to do with the fence. return http.Response( '{"success":true,"data":{"bookingid":4821,' '"status":"Miler_Assigned","reachedat":"2026-09-16T10:14:02Z"}}', 200, ); }); controller = PickupsController(); }); tearDown(() { MilerApi.client = http.Client(); Geofence.useDevice(); }); test('Arrived, 250 m away — no request', () async { useFix(riderAt(250)); final ok = await controller.updateArrivedStatus( pickupId: 4821, orderHeaderId: 4821, pickupLat: '$kTargetLat', pickupLng: '$kTargetLng', ); expect(ok, isFalse); expect(sent, isEmpty, reason: 'the fence refused; nothing may be posted'); expect(controller.lastBlockedReason, contains('Move within 100 m')); }); test('Arrived with GPS off — no request', () async { useFailure(GeofenceOutcome.serviceDisabled); final ok = await controller.updateArrivedStatus( pickupId: 4821, orderHeaderId: 4821, pickupLat: '$kTargetLat', pickupLng: '$kTargetLng', ); expect(ok, isFalse); expect(sent, isEmpty); }); test('Arrived with permission denied forever — no request', () async { useFailure(GeofenceOutcome.permissionDeniedForever); final ok = await controller.updateArrivedStatus( pickupId: 4821, orderHeaderId: 4821, pickupLat: '$kTargetLat', pickupLng: '$kTargetLng', ); expect(ok, isFalse); expect(sent, isEmpty); }); test('Arrived on a stop with no pin — no request', () async { useFix(riderAt(0)); final ok = await controller.updateArrivedStatus( pickupId: 4821, orderHeaderId: 4821, pickupLat: '0', pickupLng: '0', ); expect(ok, isFalse); expect( sent, isEmpty, reason: 'a booking with no coordinates used to be waved through — that is ' 'the bypass that made the fence decorative', ); }); test('Delivery arrived, 250 m away — no request', () async { useFix(riderAt(250)); final ok = await controller.updateDeliveryArrived( pickupId: 4821, dropLat: '$kTargetLat', dropLng: '$kTargetLng', ); expect(ok, isFalse); expect(sent, isEmpty); }); test('Delivery arrived with NO drop pin — no request', () async { // The conditional that used to wrap this call site — // `if (dLat != 0 && dLng != 0)` — skipped the fence silently for exactly // the stops where nobody could say afterwards where the rider was. useFix(riderAt(0)); final ok = await controller.updateDeliveryArrived( pickupId: 4821, dropLat: '0', dropLng: '0', ); expect(ok, isFalse); expect(sent, isEmpty); }); test('Delivered, 250 m away — no request', () async { useFix(riderAt(250)); final ok = await controller.updateDeliveredStatus( pickupId: 4821, consignmentId: '77', deliveredToName: 'Anitha R', dropLat: '$kTargetLat', dropLng: '$kTargetLng', ); expect(ok, isFalse); expect(sent, isEmpty); }); test('Delivered with NO drop pin — no request', () async { useFix(riderAt(0)); final ok = await controller.updateDeliveredStatus( pickupId: 4821, consignmentId: '77', deliveredToName: 'Anitha R', dropLat: '0', dropLng: '0', ); expect(ok, isFalse); expect(sent, isEmpty); }); test('inside the fence, the request DOES go out', () async { // The other half of the contract, and the reason this group is not just // an assertion that everything is broken. useFix(riderAt(40)); final ok = await controller.updateArrivedStatus( pickupId: 4821, orderHeaderId: 4821, pickupLat: '$kTargetLat', pickupLng: '$kTargetLng', ); expect(ok, isTrue); expect(sent, isNotEmpty); expect(sent.single, contains('/miler/bookings/4821/reached')); expect(controller.lastBlockedReason, isNull); }); }); }