import 'dart:convert'; import 'package:doormile_cx/data/api_client.dart'; import 'package:doormile_cx/data/api_exception.dart'; import 'package:doormile_cx/data/live_doormile_api.dart'; import 'package:doormile_cx/data/models.dart'; import 'package:doormile_cx/data/session_store.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; /// What [LiveDoormileApi] actually puts on the wire, checked field by field /// against the customer API document. /// /// These are not parser tests. Every one of them exists because a plausible, /// readable, wrong spelling — `otp` for `code`, `latitude` for `lat`, `min` for /// `minRupees` — costs a whole feature at runtime and nothing at compile time. /// /// Where the written contract and the running server disagree, these tests /// follow the server. The `otp`/`code` case below is why. class _Sent { http.Request? last; Map get body => jsonDecode(last!.body) as Map; } ({LiveDoormileApi api, _Sent sent}) _api(Object responseData) { final sent = _Sent(); final http.Client transport = MockClient((request) async { sent.last = request; return http.Response( jsonEncode({'success': true, 'data': responseData}), 200, headers: {'content-type': 'application/json'}, ); }); return ( api: LiveDoormileApi( client: ApiClient(httpClient: transport, sessions: MemorySessionStore()), ), sent: sent, ); } void main() { // ── This test used to assert the opposite, and that is why the bug shipped ── // // The written contract names this field `otp`. The server does not: // CxVerifyOtp reads `json:"code"` and treats a body without it as an empty // code, answering 400 "Enter the code we sent you" to every sign-in — with // any code, correct or not. Verified against production: `otp` gets that 400, // `code` gets 401 "That code did not match" for a wrong code, and a session // for a right one. // // The document was wrong and this test agreed with it, so nothing failed // until a human tried to log in. The assertion is inverted deliberately — // the server is the contract. test('OTP verification sends `code` — the field the server actually reads', () async { final t = _api({ 'accessToken': 'a', 'refreshToken': 'r', 'expiresIn': 3600, 'customer': {'id': 1042, 'name': 'Alex Kumar', 'phone': '+919876543210'}, }); final customer = await t.api.verifyOtp('9876543210', '1234'); expect(t.sent.last!.url.path, endsWith('/customer/auth/otp/verify')); expect(t.sent.body['code'], '1234'); expect(t.sent.body['identifier'], '+919876543210'); expect( t.sent.body.containsKey('otp'), isFalse, reason: 'the server ignores `otp` and reads the body as an empty code', ); expect(t.sent.last!.headers['Idempotency-Key'], isNotNull); expect(customer.name, 'Alex Kumar'); }); test('a fare estimate prices packages, and reads the rupee band back', () async { final t = _api({ 'minRupees': 240, 'maxRupees': 310, 'routeKm': 348.5, 'breakdown': {'baseFare': 180, 'additionalStopsUplift': 60}, }); final fare = await t.api.estimateFare( pickup: const Place(title: 'Home', sub: '', lat: 13.0827, lng: 80.2707), destinations: [ DestinationGroup( destination: Destination(stateCode: 'TN', districtCode: 'CHN'), packageCount: 2, ), ], ); // ── `lat`/`lng`, not `latitude`/`longitude` ── // // The written contract says the long spelling and this app sent it. The // server reads the short one, so it saw a pickup with no coordinates, could // not place it in a serviceable area, and answered every booking with // 422 `unserviceable`. Verified against production on 2026-09-23 one // request apart — same pickup, same slot: long spelling 422, short // spelling 201 and booking DM-252803. final pickup = t.sent.body['pickup'] as Map; expect(pickup['lat'], 13.0827); expect(pickup['lng'], 80.2707); expect(pickup.containsKey('latitude'), isFalse); final destination = (t.sent.body['destinations'] as List).single as Map; expect(destination['stateCode'], 'TN'); // One entry per package, and no weight on any of them — the customer is // never asked what a parcel weighs. expect((destination['packages'] as List).length, 2); expect(fare.min, 240); expect(fare.max, 310); expect(fare.routeKm, 348.5); }); test('a booking nests its destination details', () async { final t = _api({ 'reference': 'DM-482913', 'stage': 'booked', 'status': 'active', 'cancellable': true, 'createdAt': 1788775499000, 'slotId': 'slot_20260908_t2', 'pickup': { 'title': 'Home', 'sub': 'Flat 4B, Green Towers', 'latitude': 13.0827, 'longitude': 80.2707, }, 'destinations': [ { 'index': 0, 'stateName': 'Tamil Nadu', 'districtName': 'Chennai', 'packageCount': 1, 'codAmount': 450, 'trackingId': null, 'stage': null, }, ], }); await t.api.client.adoptSession( Session( accessToken: 'a', refreshToken: 'r', expiresAt: DateTime.now().add(const Duration(hours: 1)), customer: const Customer( id: '1042', name: 'Alex Kumar', phone: '+919876543210', email: '', ), ), ); final group = DestinationGroup( destination: Destination(stateCode: 'TN', districtCode: 'CHN'), ); group.details.update( recipientName: 'Priya S', building: '12/A', street: 'MG Road', instructions: 'Handle with care', ); final booking = await t.api.createBooking( pickup: const Place( title: 'Home', sub: 'Flat 4B, Green Towers', lat: 13.0827, lng: 80.2707, ), destinations: [group], slotId: 'slot_20260908_t2', ); final body = t.sent.body; final pickup = body['pickup'] as Map; expect(pickup['lat'], 13.0827); // ── The pickup carries no contact ── // // It used to send `contactName` and `contactPhone` here and this test // asserted them. The create contract's pickup is `{title, sub, lat, lng}`; // extra keys are dropped without an error, and the rider's number is // derived by the backend from the account. Two fields written on every // booking and read by nobody. expect(pickup.containsKey('contactName'), isFalse); expect(pickup.containsKey('contactPhone'), isFalse); final destination = (body['destinations'] as List).single as Map; // ── Nested, not flat ── // // The previous shape spread these across the destination and this test // asserted `details` was absent. The contract nests them, and a // destination's extra keys are dropped silently — so the whole // full-address path was being accepted with a 201 and thrown away. // // NOT yet confirmed against the running server. The `lat`/`lng` fix was // proven with two identical requests one apart; this deserves the same // before it is trusted in production. final details = destination['details'] as Map; expect(details['recipientName'], 'Priya S'); expect(details['building'], '12/A'); expect(details['street'], 'MG Road'); // The note has its own field per destination; it is not the visit's line. expect(details['instructions'], 'Handle with care'); expect(destination.containsKey('recipientName'), isFalse); // `remarks` is now only the handover person, and there is none here. expect(body.containsKey('remarks'), isFalse); // And the response — which names the state and district but sends no // codes — must still render as a destination. expect(booking.reference, 'DM-482913'); expect(booking.pickup.lat, 13.0827); final read = booking.destinations.single; expect(read.destination.label, 'Chennai, Tamil Nadu'); expect(read.index, 0); expect(read.codAmount, 450); expect(read.trackingId, isNull); }); test('the cancel window closes when the Miler arrives', () { final t = _api(const {}); expect(t.api.isCancellable(JourneyStage.onTheWay), isTrue); expect(t.api.isCancellable(JourneyStage.arrived), isFalse); expect(t.api.isCancellable(JourneyStage.pickedUp), isFalse); }); test('booking limits carry the COD cap', () { const limits = BookingLimits(); expect(limits.allowsCod, isFalse); expect( BookingLimits.fromJson(const { 'maxDestinations': 1, 'maxPackages': 20, 'maxCodAmount': 5000, }).maxCodAmount, 5000, ); }); group('error codes', () { /// Drives a real failure through [ApiClient], because the translation that /// matters happens while the response is being decoded. Future failing(int status, String code) async { final client = ApiClient( httpClient: MockClient( (_) async => http.Response( jsonEncode({ 'success': false, 'error': {'code': code, 'message': 'Something the server said'}, }), status, headers: {'content-type': 'application/json'}, ), ), sessions: MemorySessionStore(), ); try { await client.get('/bookings', authenticated: false); } on ApiException catch (e) { return e; } fail('expected $code to throw'); } test('the contract spells its codes in capitals; the client translates', () async { expect((await failing(401, 'UNAUTHORIZED')).isAuthFailure, isTrue); expect((await failing(409, 'SLOT_CAPACITY_FULL')).needsFreshSlots, isTrue); expect((await failing(400, 'SLOT_EXPIRED')).needsFreshSlots, isTrue); expect( (await failing(409, 'BOOKING_NOT_CANCELLABLE')).code, ApiException.notCancellable, ); expect( (await failing(422, 'UNSERVICEABLE_PINCODE')).code, ApiException.unserviceable, ); expect((await failing(429, 'RATE_LIMITED')).isTransient, isTrue); // The raw code is kept for the log line and the support report. expect((await failing(401, 'UNAUTHORIZED')).serverCode, 'UNAUTHORIZED'); // An unknown code still lands somewhere useful: the HTTP status. expect((await failing(404, 'NO_SUCH_THING')).code, ApiException.notFound); }); }); test('a paged list is read through its wrapper as well as beside it', () async { final client = ApiClient( httpClient: MockClient( (_) async => http.Response( jsonEncode({ 'success': true, 'data': { 'items': [ {'reference': 'DM-1'}, ], 'nextCursor': 'cur_2', 'total': 9, }, }), 200, headers: {'content-type': 'application/json'}, ), ), sessions: MemorySessionStore(), ); final page = await client.get('/bookings', authenticated: false); expect(page.rows.single['reference'], 'DM-1'); expect(page.nextCursor, 'cur_2'); expect(page.total, 9); }); }