import 'dart:convert'; import 'dart:io'; 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'; /// ───────────────────────────────────────────────────────────────────────── /// WHAT THIS CLIENT ACTUALLY PUTS ON THE WIRE /// /// Several fields in this contract are quietly load-bearing, and every one of /// them fails *silently* if it drifts — the request is accepted, or rejected /// with a generic message, and nothing on the phone says which field was wrong: /// /// • `device_token` is snake_case where everything around it is not. /// • Telemetry numbers go as **strings**; real JSON numbers fail to parse. /// • Telemetry bodies must carry **no `userid`** — identity is the token, and /// a body `userid` is how one rider's GPS could be written under another's. /// • Availability sends `Break`, not the obvious `On_Break`. /// • `vehicle-required` takes **query parameters**, not a body. /// • Consignment logs are a JSON **array**, even for one entry. /// /// None of it was covered, because `_send` reached the `http` package's /// top-level functions and could only be exercised by letting it hit the /// network. `MilerApi.client` is the seam; these assert the request itself. /// ───────────────────────────────────────────────────────────────────────── void main() { TestWidgetsFlutterBinding.ensureInitialized(); late List sent; /// Captures every request and answers with [body]. void stub({ Map body = const {'success': true}, int code = 200, }) { sent = []; MilerApi.client = MockClient((req) async { sent.add(req); return http.Response( json.encode(body), code, headers: const {'content-type': 'application/json'}, ); }); } Map lastBody() => json.decode(sent.single.body) as Map; setUp(() async { // The canned backend intercepts *above* the HTTP client, so with it on // there is no request to assert against — which is precisely why these // fields went uncovered for so long. MockBackend.enabled = false; SharedPreferences.setMockInitialValues({'authtoken': 'test-token'}); await ApiConfig.setToken('test-token'); stub(); }); tearDown(() { MilerApi.client = http.Client(); MockBackend.enabled = kMockBackend; }); group('authentication', () { test('every authenticated call carries the bearer token', () async { await MilerApi.getProfile(); expect(sent.single.headers['Authorization'], 'Bearer test-token'); }); test( 'login and verify-pin do not, because there is nothing to carry yet', () async { await MilerApi.login('9787698259'); expect(sent.single.headers.containsKey('Authorization'), isFalse); stub(); await MilerApi.verifyPin(phone: '9787698259', pin: '1234'); expect(sent.single.headers.containsKey('Authorization'), isFalse); }, ); test('the rider surface is always partition 1001', () async { await MilerApi.login('9787698259'); expect(lastBody()['configid'], 1001); }); test('the PIN goes as a string, so a leading zero survives', () async { await MilerApi.verifyPin(phone: '9787698259', pin: '0512'); expect(lastBody()['pin'], '0512'); expect(lastBody()['pin'], isA()); }); // ── reset-pin is admin/ops, and the rider app must never reach it ── // // It was once open, and reset-pin followed by verify-pin took over any // rider account given nothing but a phone number. test('no source file in the app ever builds a reset-pin request', () { // Asserted against the source rather than a stub, because the failure // mode is somebody *adding* the call — and a runtime test only catches a // route that is already being exercised. Comments explaining why it is // absent are expected; a request path is not. final offenders = []; for (final f in Directory('lib').listSync(recursive: true)) { if (f is! File || !f.path.endsWith('.dart')) continue; for (final line in f.readAsLinesSync()) { final code = line.trim(); if (code.startsWith('//') || code.startsWith('///')) continue; // The route, not the word: explaining in a log message why the app // does not call it is exactly the right thing to do. if (code.contains('/miler/reset-pin')) offenders.add(f.path); } } expect( offenders, isEmpty, reason: 'reset-pin needs an admin token; reset-pin followed by ' 'verify-pin once took over any rider account given only a phone ' 'number', ); }); }); group('profile and device', () { test('the device token field is snake_case', () async { await MilerApi.setDeviceToken('fcm-abc'); expect(sent.single.method, 'PUT'); expect(sent.single.url.path, endsWith('/miler/device-token')); expect(lastBody().keys, contains('device_token')); expect(lastBody().keys, isNot(contains('deviceToken'))); expect(lastBody()['device_token'], 'fcm-abc'); }); }); group('duty and availability', () { test( 'availability sends `status`, and the break value is `Break`', () async { await MilerApi.setAvailability('Break'); expect(lastBody()['status'], 'Break'); expect( MilerApi.availabilityStatuses, contains('Break'), reason: 'the obvious guess, On_Break, is the wrong one', ); expect(MilerApi.availabilityStatuses, isNot(contains('On_Break'))); }, ); test('ending duty and breaks are PUT, starting them is POST', () async { await MilerApi.startDuty(lat: 11.0, lon: 76.9); expect(sent.single.method, 'POST'); stub(); await MilerApi.endDuty(); expect(sent.single.method, 'PUT'); stub(); await MilerApi.endBreak(); expect(sent.single.method, 'PUT'); }); }); group('telemetry', () { test('every numeric-looking field is serialized as a string', () async { await MilerApi.postLog( latitude: 11.0168, longitude: 76.9558, speed: 4.2, heading: 180.0, accuracy: 8.0, battery: 73, ); final b = lastBody(); for (final key in const [ 'latitude', 'longitude', 'speed', 'heading', 'accuracy', 'battery', ]) { expect(b[key], isA(), reason: '$key must not be a JSON number'); } expect(b['latitude'], '11.0168'); expect(b['battery'], '73'); }); test('no telemetry body carries a userid', () async { await MilerApi.postLog(latitude: 1, longitude: 2); expect( lastBody().keys, isNot(contains('userid')), reason: 'identity is the token; a body userid is how one rider\'s GPS ' 'gets written under another\'s', ); stub(); await MilerApi.postStatus('Available'); expect(lastBody().keys, isNot(contains('userid'))); }); test('consignment logs are a JSON array, even for one entry', () async { await MilerApi.postConsignmentLogs([ const ConsignmentLogEntry( consignmentId: 42, status: 'Out_for_Delivery', latitude: 11.0, longitude: 76.9, ), ]); final decoded = json.decode(sent.single.body); expect(decoded, isA>()); expect((decoded as List).single, isA>()); }); test('location writes lat/lon/pincode/speed/heading', () async { await MilerApi.pushLocation( latitude: 11.0, longitude: 76.9, pincode: '641004', speed: 3.0, heading: 90.0, ); expect(sent.single.method, 'PUT'); expect(sent.single.url.path, endsWith('/miler/location')); expect(lastBody().keys, containsAll(['latitude', 'longitude'])); }); }); group('pickup and delivery', () { test('vehicle-required uses query parameters, not a body', () async { await MilerApi.vehicleRequired( 101, type: 'Truck', reason: 'Parcel too large for the box', ); expect( sent.single.url.queryParameters, isNotEmpty, reason: 'this one takes query parameters where its neighbours take ' 'a JSON body', ); expect(sent.single.body, anyOf(isEmpty, equals('null'))); }); test('pickup-complete is its own transition, not a status write', () async { await MilerApi.pickupComplete(101); expect(sent.single.method, 'POST'); expect( sent.single.url.path, endsWith('/miler/bookings/101/pickup-complete'), ); }); test('a consignment is delivered and skipped by its own id', () async { await MilerApi.deliver( 7, deliveredToName: 'Joe Mathew', lat: 11.0, lon: 76.9, ); expect(sent.single.url.path, endsWith('/miler/consignments/7/deliver')); expect(lastBody()['deliveredtoname'], 'Joe Mathew'); // ── The OTP is conditional, and its absence is a real answer ── // // Nothing generates a delivery OTP, so the handler records whether one // was presented rather than checking it. A caller without one — a // milk-run drop handed over with no code — must not invent a placeholder. expect(lastBody().keys, isNot(contains('otp'))); stub(); await MilerApi.skipConsignment(7, reason: 'Customer not available'); expect(sent.single.url.path, endsWith('/miler/consignments/7/skip')); expect(lastBody()['reason'], 'Customer not available'); }); }); group('errors are values, not exceptions', () { test( 'a rejected call comes back as a failed result with the message', () async { stub(body: {'success': false, 'message': 'incorrect PIN'}, code: 401); final res = await MilerApi.getProfile(); expect(res.ok, isFalse); expect(res.status, 401); expect(res.message, 'incorrect PIN'); }, ); test('a body that is not JSON does not throw', () async { MilerApi.client = MockClient( (_) async => http.Response('502 Bad Gateway', 502), ); final res = await MilerApi.getProfile(); expect(res.ok, isFalse); expect(res.status, 502); }); test('a transport failure is a result, not an uncaught throw', () async { MilerApi.client = MockClient((_) async => throw const SocketFailure()); final res = await MilerApi.getProfile(); expect(res.ok, isFalse); expect(res.status, 0); }); }); } /// Stands in for a dead network without depending on `dart:io` in a test that /// otherwise needs none. class SocketFailure implements Exception { const SocketFailure(); }