import 'package:doormile_cx/data/app_config.dart'; import 'package:doormile_cx/data/doormile_api.dart'; import 'package:doormile_cx/data/location_service.dart'; import 'package:doormile_cx/data/models.dart'; import 'package:doormile_cx/state/app_state.dart'; import 'package:flutter_test/flutter_test.dart'; /// A programmable stand-in for the service layer. /// /// Not the mock API — that one invents a whole product. This one answers /// exactly what a test sets and records exactly what it was asked, which is /// what the wiring between [AppState] and the contract needs proving. class FakeApi extends DoormileApi { FakeApi({this.limits = const BookingLimits(maxPackages: 20, maxDestinations: 5)}); BookingLimits limits; Object? limitsError; final List createKeys = []; int createFailures = 0; Object? createError; final List pages = []; int pageCalls = 0; final List cursors = []; List slots = const [ PickupSlot( id: 'slot_20260905_t1', day: 'Today', window: '2:00 – 4:00 PM', available: true, tag: 'Fastest pickup', milersNearby: 4, caption: 'Arriving in approx. 45 mins', ), ]; int slotCalls = 0; /// Reverse-geocode answers, keyed by latitude, with a delay each — so a test /// can make an earlier request land after a later one. final Map geocodes = {}; // PIN sign-in is not what this file exercises; these satisfy the interface. @override Future checkPhone(String phone) async => PhoneCheck(phone: phone, registered: false, pinSet: false); @override Future setPin({ required String phone, required String pin, String? name, }) async => Customer(id: 'c', name: name ?? '', phone: phone, email: ''); @override Future verifyPin({ required String phone, required String pin, }) async => Customer(id: 'c', name: '', phone: phone, email: ''); @override Future getBookingLimits({Place? pickup}) async { if (limitsError != null) throw limitsError!; return limits; } @override Future createBooking({ required Place pickup, required List destinations, required String? slotId, FareEstimate? fare, String? contactPhone, String? contactName, String? idempotencyKey, }) async { createKeys.add(idempotencyKey); if (createFailures > 0) { createFailures--; throw createError ?? ApiException(ApiException.network, 'offline'); } return Booking( reference: 'DM-482913', pickup: pickup, destinations: destinations.map((d) => d.copy()).toList(), slotId: slotId ?? '', createdAt: DateTime.now(), ); } @override Future getBookingPage({ BookingStatus? status, String? cursor, int limit = 20, }) async { cursors.add(cursor); if (pageCalls >= pages.length) return BookingPage.empty; return pages[pageCalls++]; } @override Future> getPickupSlots({Place? pickup}) async { slotCalls++; return slots; } @override Future reverseGeocode({double? lat, double? lng}) async { final answer = geocodes[lat]; if (answer == null) { return Place(title: 'Pin', sub: 'somewhere', lat: lat, lng: lng); } await Future.delayed(answer.$1); return answer.$2; } // ---- everything the interface needs but these tests do not exercise ------ @override Future sendOtp(String identifier) async => OtpChallenge.unspecified; @override Future signUp({ required String name, required String phone, String? email, }) async => OtpChallenge.unspecified; @override Future verifyOtp( String identifier, String code, { String? name, }) async => Customer( id: 'c1', name: name ?? 'Joe', phone: identifier, email: '', ); @override Future> getServiceableStates() async => const []; @override Future> getServiceableDistricts(String stateCode) async => const []; @override District? districtByCode(String? code) => null; @override PickupSlot? slotById(String? id) { for (final slot in slots) { if (slot.id == id) return slot; } return null; } @override Future> searchPlaces( String query, { double? lat, double? lng, }) async => const []; @override Future estimateFare({ required Place pickup, required List destinations, }) async => const FareEstimate(min: 49, max: 64, paymentMethod: 'UPI'); @override Future cancelBooking(String reference, String? reason) async {} @override @override Future getBooking(String reference) async => null; @override bool isCancellable(JourneyStage stage) => stage.index < JourneyStage.arrived.index; final List<(String, JourneyStage)> stageCalls = []; @override Future setStage(String reference, JourneyStage stage) async { stageCalls.add((reference, stage)); } } Booking _booking(String reference) => Booking( reference: reference, pickup: const Place(title: 'Home', sub: 'Coimbatore'), destinations: [DestinationGroup()], slotId: 'slot_1', createdAt: DateTime.now(), ); AppState _state(FakeApi api) => AppState(api: api, location: FixedLocationService()); void main() { group('nothing on screen is invented', () { test('a fresh state holds no bookings until the server sends some', () { // There is no seed, no demo data and no constructor side effect that // could put a booking in front of a customer. The list starts empty and // is only ever filled by a response. late AppState state; expect(() => state = _state(FakeApi()), returnsNormally); expect(state.orders, isEmpty); }); test('the orders list is the server\'s, adopted whole', () async { final api = FakeApi() ..pages.add(BookingPage(bookings: [_booking('DM-9')])); final state = _state(api); await state.refreshOrders(); expect(state.orders.single.reference, 'DM-9'); // A later page that no longer contains it removes it, rather than the // client keeping a copy the backend has forgotten. api.pages.add(const BookingPage(bookings: [])); await state.refreshOrders(); expect(state.orders, isEmpty); }); test('a stage move goes through the server and comes back read', () async { final api = FakeApi() ..pages.add(BookingPage(bookings: [_booking('DM-1')])); final state = _state(api); await state.refreshOrders(); await state.advanceStage(state.orders.single, JourneyStage.delivered); // The client asked; it did not apply anything to its own copy. expect(api.stageCalls, [('DM-1', JourneyStage.delivered)]); expect(state.orders.single.stage, JourneyStage.booked); expect(state.orders.single.destinations.single.trackingId, isNull); }); }); group('which backend this build believes it is', () { // `DM_ENV` used to default to `staging` for EVERY build, release included, // while `_stagingBase` is `_prodBase`. So a release APK built without // `--dart-define=DM_ENV=prod` talked to production and reported // `isProd == false` — and `isProd` is what the non-network guards read: the // Account screen's Build panel, `allowStageOverride`, `setStage`'s refusal, // and the environment name on every bug report. test('an unflagged debug build is staging, not production', () { // This test run carries no DM_ENV, and under test kReleaseMode is false. expect(AppConfig.environment, DoormileEnvironment.staging); expect(AppConfig.isProd, isFalse); }); test('staging and prod resolve to the same host today', () { // The backend has not named a staging host, so the environment decides // what the build CALLS ITSELF, not who it talks to. Worth pinning: the // day a real staging host appears, this test failing is the reminder that // the default now changes where traffic goes. expect(AppConfig.baseUrl, 'https://api.doormile.com/api/v1'); }); test('the environment is named on every build description', () { // What a bug report carries. A production session that called itself // "staging" is how a real incident gets triaged against the wrong server. expect(AppConfig.describe, contains(AppConfig.environment.name)); }); // NOT ASSERTABLE FROM HERE, and stated rather than faked: the release // branch of `environment` is `kReleaseMode ? prod : staging`, and // kReleaseMode is a compile-time const that is false under `flutter test`. // There is no way to exercise the release arm from a debug test run, so // this note is the coverage. Verify it with: // // flutter build apk --release → Account shows no Build panel // flutter build apk --release --dart-define=DM_ENV=staging // → Build panel returns }); group('development access', () { test('no dev token is compiled into this build', () { // A pasted token is not present unless a define supplies one. expect(AppConfig.hasDevToken, isFalse); expect(AppConfig.describe, isNot(contains('DEV TOKEN'))); }); test('an offline build says so, every time', () { // The offline build is back, and this is the guarantee that makes it // safe to have. It was deleted once because a build answering from the // fake looked exactly like a build answering from the server — which // cost two rounds of hunting for bookings in the admin console that had // never left the phone. // // A test run IS an offline build, so this asserts the announcement in // the one place it can be asserted at all. expect(AppConfig.useDevData, isTrue); expect(AppConfig.describe, contains('DEV DATA (offline)')); expect( AppConfig.describe, isNot(contains(AppConfig.baseUrl)), reason: 'naming a host a build never contacts is the confusion this ' 'whole guard exists to prevent', ); }); test('no dev token and no auto-login are compiled in', () { // The two ways to skip the login SCREEN without faking the login. Both // hold a server-issued token; neither define is passed to this run. expect(AppConfig.hasAutoLogin, isFalse); expect(AppConfig.hasDevToken, isFalse); }); test('the auto-login flag can never fire under test', () { // The widget tests drive the real entrance — phone, code, and the guard // that refuses to open the code screen when no code was sent. A session // waiting for them at launch would delete that coverage and nothing // would fail, so the flag is inert under FLUTTER_TEST whatever the // defines say. expect(AppConfig.isTest, isTrue); expect(AppConfig.hasAutoLogin, isFalse); }); // The guarantee that matters cannot be asserted from a debug test — every // switch is `!kReleaseMode`, so `autoLoginIdentifier`, `hasDevToken` and // `allowStageOverride` are all forced empty/false in a release build, // whatever the defines say. That is what keeps a bypass out of the store. }); group('booking limits', () { test('a failed limits call falls back to one destination, not five', () async { final api = FakeApi()..limitsError = ApiException('network', 'offline'); final state = _state(api); await Future.delayed(Duration.zero); // Multi-destination is undeliverable until the Miler build lands. The // permissive default is the one that strands a parcel. expect(state.limits.maxDestinations, 1); expect(state.allowsMultipleDestinations, isFalse); expect(state.addDestination(), isFalse); }); test('the served cap is authoritative when it arrives', () async { final state = _state(FakeApi()); await state.loadLimits(); expect(state.limits.maxDestinations, 5); expect(state.allowsMultipleDestinations, isTrue); }); test('a zero or missing cap resolves to one, never to none', () { expect(BookingLimits.fromJson(const {}).maxDestinations, 1); expect(BookingLimits.fromJson(const {}).maxPackages, 20); expect( BookingLimits.fromJson(const {'maxDestinations': 0, 'maxPackages': 0}) .maxDestinations, 1, ); expect( BookingLimits.fromJson(const {'maxPackages': 0}).maxPackages, 1, ); }); }); group('pickup slots', () { test('starting a booking drops yesterday\'s slots', () async { final api = FakeApi(); final state = _state(api); await state.loadSlots(); await state.loadSlots(); expect(api.slotCalls, 1, reason: 'cached within one booking'); final epoch = state.slotsEpoch; state.startBooking(); await state.loadSlots(); expect(api.slotCalls, 2); expect(state.slotsEpoch, greaterThan(epoch)); }); test('a refused slot clears the cache and the choice', () async { final api = FakeApi() ..createFailures = 1 ..createError = ApiException( ApiException.invalid, 'That pickup time has passed — pick a new slot', ); final state = _state(api); state.startBooking(); state.pickup = const Place(title: 'Home', sub: 'CBE', lat: 11, lng: 76); state.draftPickup = state.pickup; await state.loadSlots(); state.selectSlot(api.slots.first); await expectLater(state.confirmBooking(), throwsA(isA())); expect(state.draftSlotId, isNull); expect(state.slotsCache, isNull); }); test('the parser keeps every field the picker renders', () { final slot = PickupSlot.fromJson(const { 'id': 'slot_20260905_t1', 'day': 'Today', 'window': '2:00 – 4:00 PM', 'available': true, 'tag': 'Fastest pickup', 'milersNearby': 4, 'caption': 'Arriving in approx. 45 mins', 'note': null, }); expect(slot.tag, 'Fastest pickup'); expect(slot.milersNearby, 4); expect(slot.caption, 'Arriving in approx. 45 mins'); expect(slot.label, 'Today · 2:00 – 4:00 PM'); }); }); group('idempotency', () { test('a retried booking reuses its key; a new booking mints one', () async { final api = FakeApi()..createFailures = 1; final state = _state(api); state.startBooking(); state.pickup = const Place(title: 'Home', sub: 'CBE', lat: 11, lng: 76); state.draftPickup = state.pickup; state.selectSlot(api.slots.first); await expectLater(state.confirmBooking(), throwsA(isA())); await state.confirmBooking(); expect(api.createKeys.length, 2); expect(api.createKeys[0], isNotNull); // Same intent, same key — the server replays rather than booking twice. expect(api.createKeys[0], api.createKeys[1]); state.startBooking(); state.draftPickup = state.pickup; state.selectSlot(api.slots.first); await state.confirmBooking(); expect(api.createKeys[2], isNot(api.createKeys[1])); }); }); group('keyset pagination', () { test('appends pages and follows the cursor', () async { final api = FakeApi() ..pages.addAll([ BookingPage(bookings: [_booking('DM-1'), _booking('DM-2')], nextCursor: 'c2'), BookingPage(bookings: [_booking('DM-3')], nextCursor: null), ]); final state = _state(api); await state.refreshOrders(); expect(state.orders.map((o) => o.reference), ['DM-1', 'DM-2']); expect(state.hasMoreOrders, isTrue); await state.loadMoreOrders(); expect(state.orders.map((o) => o.reference), ['DM-1', 'DM-2', 'DM-3']); expect(state.hasMoreOrders, isFalse); expect(api.cursors, [null, 'c2']); }); test('a row that arrives on two pages is shown once', () async { final api = FakeApi() ..pages.addAll([ BookingPage(bookings: [_booking('DM-1')], nextCursor: 'c2'), BookingPage(bookings: [_booking('DM-1'), _booking('DM-2')]), ]); final state = _state(api); await state.refreshOrders(); await state.loadMoreOrders(); expect(state.orders.map((o) => o.reference), ['DM-1', 'DM-2']); }); test('a failed page keeps the cursor so the next scroll retries it', () async { final api = FakeApi() ..pages.add(BookingPage(bookings: [_booking('DM-1')], nextCursor: 'c2')); final state = _state(api); await state.refreshOrders(); // The fake runs out of pages and returns an empty one; the cursor then // clears normally. What matters is that nothing throws at the UI. await state.loadMoreOrders(); expect(state.loadingMoreOrders, isFalse); }); }); group('pickup point', () { test('the latest reverse-geocode wins, however the earlier one lands', () async { final api = FakeApi() ..geocodes[11.0] = ( const Duration(milliseconds: 80), const Place(title: 'First drag', sub: 'stale', lat: 11.0, lng: 76.0), ) ..geocodes[12.0] = ( const Duration(milliseconds: 10), const Place(title: 'Second drag', sub: 'fresh', lat: 12.0, lng: 77.0), ); final state = _state(api); final first = state.setPickupFromPin(11.0, 76.0); final second = state.setPickupFromPin(12.0, 77.0); await Future.wait([first, second]); expect(state.pickup!.title, 'Second drag'); expect(state.resolvingPickup, isFalse); }); test('a refused permission still leaves a usable pickup', () async { final state = AppState( api: FakeApi(), location: FixedLocationService.denied(LocationDenial.deniedForever), ); await state.detectPickupLocation(); expect(state.locationDenial, LocationDenial.deniedForever); // Falls back to the service-area centre rather than stranding the form, // and search still overrides it. expect(state.pickup, isNotNull); state.setPickup( const Place(title: 'Brookefields Mall', sub: 'CBE', lat: 10.99, lng: 76.96), ); expect(state.pickup!.title, 'Brookefields Mall'); expect(state.hasPickupPoint, isTrue); expect(state.locationDenial, isNull); }); }); group('booking parser', () { final json = { 'reference': 'DM-482913', 'stage': 'in_transit', 'status': 'active', 'cancellable': false, 'createdAt': 1757056800000, 'pickup': { 'title': '12 Nehru Street', 'sub': 'Gandhipuram, Coimbatore 641012', 'lat': 11.0183, 'lng': 76.9725, }, 'slotId': 'slot_t_1', 'destinations': [ { 'stateCode': 'TN', 'stateName': 'Tamil Nadu', 'districtCode': 'TN-MAA', 'districtName': 'Chennai', 'packageCount': 2, 'district': { 'code': 'TN-MAA', 'name': 'Chennai', 'available': true, 'hub': 'Chennai Guindy Hub', 'promise': 'Next-day delivery', 'lat': 13.08, 'lng': 80.27, }, 'details': { 'street': '12th Main', 'recipientName': 'Meera S', 'recipientPhone': '+919884412210', }, 'trackingId': 'DMX10482913', 'stage': 'in_transit', 'verification': { 'weightKg': 2.8, 'photos': ['https://cdn.doormile.com/pv/abc.jpg'], 'capturedAt': 1757060400000, 'capturedBy': 'Arun Kumar', }, }, ], 'miler': { 'name': 'Arun Kumar', 'vehicle': 'TN 37 BX 4412', 'phone': '+919000011223', 'rating': 4.9, 'trips': 1240, 'vehicleType': 'E-Scooter', }, 'deliveryAgent': null, 'milerDistanceKm': null, 'milerEtaMinutes': null, 'milersInZone': 3, 'routeKm': 6.4, 'expectedDelivery': 'Thu, 12 Sep', 'fare': { 'min': 49, 'max': 64, 'paymentMethod': 'UPI · Cash at doorstep', 'parcel': 'Standard box (up to 3 kg)', }, 'amountPaid': 64, 'deliveredAt': null, 'cancelReason': null, 'history': [ {'stage': 'booked', 'at': 1757056800000}, {'stage': 'assigned', 'at': 1757057400000}, {'stage': 'in_transit', 'at': 1757062200000}, ], }; test('reads the whole canonical object, not a subset', () { final booking = Booking.fromJson(json); expect(booking.reference, 'DM-482913'); expect(booking.stage, JourneyStage.inTransit); expect(booking.status, BookingStatus.active); expect(booking.cancellable, isFalse); expect(booking.createdAt.millisecondsSinceEpoch, 1757056800000); expect(booking.pickup.title, '12 Nehru Street'); expect(booking.slotId, 'slot_t_1'); expect(booking.routeKm, 6.4); expect(booking.milersInZone, 3); expect(booking.expectedDelivery, 'Thu, 12 Sep'); expect(booking.amountPaid, 64); expect(booking.deliveredAt, isNull); expect(booking.miler!.name, 'Arun Kumar'); expect(booking.miler!.trips, 1240); expect(booking.deliveryAgent, isNull); expect(booking.fare!.range, '₹49 – ₹64'); expect(booking.history.length, 3); expect(booking.history.first.stage, JourneyStage.booked); expect(booking.timeOf(JourneyStage.assigned), isNotNull); final group = booking.destinations.single; expect(group.destination.label, 'Chennai, Tamil Nadu'); expect(group.packageCount, 2); expect(group.trackingId, 'DMX10482913'); expect(group.stage, JourneyStage.inTransit); expect(group.details.recipientName, 'Meera S'); expect(group.district!.hub, 'Chennai Guindy Hub'); expect(group.hub, isNotNull, reason: 'district coordinates draw the route'); expect(group.verification!.weightLabel, '2.8 kg'); expect(group.verification!.photos.single, endsWith('abc.jpg')); }); test('before order_created there is no order to track', () { final pending = Map.from(json) ..['stage'] = 'assigned' ..['amountPaid'] = null ..['destinations'] = [ { 'stateCode': 'TN', 'stateName': 'Tamil Nadu', 'districtCode': 'TN-MAA', 'districtName': 'Chennai', 'packageCount': 1, }, ]; final booking = Booking.fromJson(pending); final group = booking.destinations.single; expect(group.trackingId, isNull); expect(group.stage, isNull); expect(group.verification, isNull); expect(booking.amountPaid, isNull); expect(booking.hasOrders, isFalse); }); test('an unknown stage key falls back to booked', () { final unknown = Map.from(json) ..['stage'] = 'delivery_failed'; expect(Booking.fromJson(unknown).stage, JourneyStage.booked); expect(JourneyStage.fromKey(null), JourneyStage.booked); expect(JourneyStage.fromKey('delivered'), JourneyStage.delivered); }); test('a cancelled booking parses even when the server thins it out', () { final booking = Booking.fromJson(const { 'reference': 'DM-449220', 'status': 'cancelled', 'stage': 'booked', 'cancelReason': 'Package not ready', }); expect(booking.status, BookingStatus.cancelled); expect(booking.isCancelled, isTrue); expect(booking.cancelReason, 'Package not ready'); expect(booking.destinations, hasLength(1)); expect(booking.slotId, ''); }); test('timestamps are epoch millis, and nothing ambiguous is guessed', () { expect( epochMillis(1757056800000)!.millisecondsSinceEpoch, 1757056800000, ); expect(epochMillis('1757056800000')!.millisecondsSinceEpoch, 1757056800000); expect(epochMillis('2026-09-05T14:30:00+05:30'), isNotNull); expect(epochMillis(null), isNull); // A naive local string is ambiguous by 5h30m here. Refusing it is how the // tracking screen avoids dating a parcel to yesterday. expect(epochMillis('2026-09-05 14:30:00'), isNull); }); test('a destination round-trips into the create request', () { final group = DestinationGroup( destination: Destination(stateCode: 'TN', districtCode: 'TN-MAA'), packageCount: 3, ); expect(group.toBookingJson(), { 'stateCode': 'TN', 'districtCode': 'TN-MAA', 'packageCount': 3, }); // ── Nested under `details`, with the pin as `{lat, lng}` ── // // This asserted the flat shape and `latitude`/`longitude`. Both are // dropped by the server without an error, which is why the whole // full-address path was reaching the Miler with nothing on it. group.details.update(recipientName: 'Meera S', building: '12/A'); group.details.pin = const MapPin(13.085, 80.21); expect(group.toBookingJson(), { 'stateCode': 'TN', 'districtCode': 'TN-MAA', 'packageCount': 3, 'details': { 'recipientName': 'Meera S', 'building': '12/A', 'pin': {'lat': 13.085, 'lng': 80.21}, }, }); }); test('a PATCH clears with an empty string, not with null', () { // ── The old shape could not clear anything ── // // It sent `null` for an unset field and a comment said that cleared it. // The server writes only non-nil values, so `null` means "leave this // alone" — a customer could add a landmark and never remove one. `""` // clears a text field and `pin {0,0}` clears the pin. final details = DeliveryDetails(street: '12th Main'); final patch = details.toPatchJson(); expect(patch['street'], '12th Main'); expect(patch['landmark'], ''); expect(patch['instructions'], ''); expect(patch['pin'], {'lat': 0, 'lng': 0}); final pinned = DeliveryDetails(street: 'x')..pin = const MapPin(13.0, 80.0); expect(pinned.toPatchJson()['pin'], {'lat': 13.0, 'lng': 80.0}); }); }); }