import 'dart:async'; import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:miler/data/api_config.dart'; import 'package:miler/data/miler_api.dart'; import 'package:miler/data/mock_backend.dart'; import 'package:miler/data/mutation_guard.dart'; /// ───────────────────────────────────────────────────────────────────────── /// ONE PRESS, ONE REQUEST /// /// Every business-critical action here is a POST the backend is not idempotent /// about. A rider presses them on a moving bike, with gloves, on a screen he /// cannot look at for long — so a double tap is not an edge case. Two /// `duty/start` calls are a server error; two `payment` calls are two payments; /// two `accept` calls race each other to decide what the list shows. /// ───────────────────────────────────────────────────────────────────────── void main() { TestWidgetsFlutterBinding.ensureInitialized(); late int calls; late Completer release; /// A server that does not answer until the test lets it, so a second press /// genuinely lands while the first is still in flight. void stubSlow({int code = 200, Map? body}) { calls = 0; release = Completer(); MilerApi.client = MockClient((_) async { calls++; await release.future; return http.Response( json.encode(body ?? {'success': true}), code, headers: const {'content-type': 'application/json'}, ); }); } setUp(() async { MockBackend.enabled = false; MutationGuard.reset(); SharedPreferences.setMockInitialValues({'authtoken': 'test-token'}); await ApiConfig.setToken('test-token'); }); tearDown(() { MilerApi.client = http.Client(); MockBackend.enabled = kMockBackend; MutationGuard.reset(); MilerApi.onUnauthorized = null; }); test('a double tap on accept sends one request', () async { stubSlow(); final first = MilerApi.acceptAssignment(1042); final second = MilerApi.acceptAssignment(1042); release.complete(); final results = await Future.wait([first, second]); expect(calls, 1, reason: 'the second press must not reach the server'); expect(results.where((r) => r.ok).length, 1); // ── The dropped press is a failure, not a silent null ── // // A caller that does not know about the guard still has to take its error // path rather than reading nothing as success. final dropped = results.firstWhere((r) => !r.ok); expect(dropped.message, contains('already going through')); }); test('two different assignments are not blocked by each other', () async { stubSlow(); final a = MilerApi.acceptAssignment(1); final b = MilerApi.acceptAssignment(2); release.complete(); await Future.wait([a, b]); expect(calls, 2, reason: 'the guard is keyed by resource, not by verb'); }); test( 'the key frees on completion, so a genuine retry goes through', () async { stubSlow(); final first = MilerApi.acceptAssignment(7); release.complete(); await first; stubSlow(); final retry = MilerApi.acceptAssignment(7); release.complete(); await retry; expect(calls, 1, reason: 'the second stub saw a fresh request'); }, ); test('a failed mutation frees its key too', () async { // A guard that leaks on failure is worse than no guard: the rider's retry // would be dropped for the rest of the session. MilerApi.client = MockClient((_) async => throw const _Dead()); await MilerApi.pickupComplete(55); expect(MutationGuard.isBusy('pickup-complete:55'), isFalse); }); test('duty start and end share one key, in both directions', () async { stubSlow(); final start = MilerApi.startDuty(lat: 11, lon: 76); final end = MilerApi.endDuty(); release.complete(); await Future.wait([start, end]); expect( calls, 1, reason: 'a rider tapping the switch again because the first tap "did ' 'nothing" is the commonest way to double-fire duty', ); }); group('every non-idempotent mutation is guarded', () { Future expectGuarded( String label, Future Function() call, ) async { stubSlow(); final a = call(); final b = call(); release.complete(); await Future.wait([a, b]); expect(calls, 1, reason: '$label is not protected against a double tap'); } test('the pickup flow', () async { await expectGuarded('reached', () => MilerApi.reached(9)); await expectGuarded('parcel', () => MilerApi.submitParcels(9, const [])); await expectGuarded( 'payment', () => MilerApi.submitPayment(9, paymentMode: 'Cash', amount: 120), ); await expectGuarded('pickup-complete', () => MilerApi.pickupComplete(9)); }); test('the delivery flow', () async { await expectGuarded( 'deliver', () => MilerApi.deliver(3, deliveredToName: 'Joe'), ); await expectGuarded( 'skip', () => MilerApi.skipConsignment(3, reason: 'Nobody home'), ); }); test('availability and breaks', () async { await expectGuarded( 'availability', () => MilerApi.setAvailability('Available'), ); await expectGuarded('break', () => MilerApi.startBreak('Personal')); }); test('reject and cancel', () async { await expectGuarded( 'reject', () => MilerApi.rejectAssignment(4, reason: 'Too far'), ); await expectGuarded( 'cancel', () => MilerApi.cancelBooking(4, reason: 'Shop closed'), ); }); }); group('a 401 ends the session, once, centrally', () { test('the dead token is dropped and the app is told', () async { var told = 0; MilerApi.onUnauthorized = () => told++; MilerApi.client = MockClient( (_) async => http.Response( json.encode({'success': false, 'message': 'token expired'}), 401, headers: const {'content-type': 'application/json'}, ), ); final res = await MilerApi.getProfile(); expect(res.ok, isFalse); expect(res.status, 401); expect(told, 1); expect( await ApiConfig.getToken(), isNull, reason: 'nothing may retry with a token the server has refused', ); }); test('a wrong PIN does not log anybody out', () async { var told = 0; MilerApi.onUnauthorized = () => told++; MilerApi.client = MockClient( (_) async => http.Response( json.encode({'success': false, 'message': 'incorrect PIN'}), 401, headers: const {'content-type': 'application/json'}, ), ); await MilerApi.verifyPin(phone: '9787698259', pin: '0000'); expect( told, 0, reason: 'a 401 on an auth route is about a credential, not an expired ' 'session — clearing state here is a logout in response to a typo', ); }); test('a 429 is a failure, not a session end', () async { var told = 0; MilerApi.onUnauthorized = () => told++; MilerApi.client = MockClient( (_) async => http.Response( json.encode({'success': false, 'message': 'rate limited'}), 429, headers: const {'content-type': 'application/json'}, ), ); final res = await MilerApi.assignments(); expect(res.ok, isFalse); expect(res.status, 429); expect(res.message, 'rate limited'); expect(told, 0); expect(await ApiConfig.getToken(), isNotNull); }); }); } class _Dead implements Exception { const _Dead(); }