Files
doormile_customer_app/test/api_integration_test.dart
Thiru-tenext 0d66627c3c Replace the customer app with Doormile CX
Book a pickup, track it to delivery — the rebuilt customer app.

- Design language from doormile-screens.html: brand #8F0F06, Manrope +
  Geist Mono (variable fonts), bordered cards instead of shadows, crimson
  brand headers, sliding tab indicator, mono for anything read digit by digit.
- lib/data (one live API implementation, plus a debug-only offline fake),
  lib/state, lib/ui (tokens, widgets, screens).
- 84 tests, plus a design snapshot harness that renders every screen with the
  real fonts: flutter test test/design_snapshot_test.dart --run-skipped
  --update-goldens

This replaces the previous app (pubspec 'doormile', app id
com.doormile.customer). That tree remains in history at 6c7d656; note its
android/app/google-services.json is not carried over, and the application id
here is in.doormile.customer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 16:07:33 +05:30

674 lines
22 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<String?> createKeys = [];
int createFailures = 0;
Object? createError;
final List<BookingPage> pages = [];
int pageCalls = 0;
final List<String?> cursors = [];
List<PickupSlot> 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<double, (Duration, Place)> geocodes = {};
@override
Future<BookingLimits> getBookingLimits({Place? pickup}) async {
if (limitsError != null) throw limitsError!;
return limits;
}
@override
Future<Booking> createBooking({
required Place pickup,
required List<DestinationGroup> destinations,
required String? slotId,
FareEstimate? fare,
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<BookingPage> getBookingPage({
BookingStatus? status,
String? cursor,
int limit = 20,
}) async {
cursors.add(cursor);
if (pageCalls >= pages.length) return BookingPage.empty;
return pages[pageCalls++];
}
@override
Future<List<PickupSlot>> getPickupSlots({Place? pickup}) async {
slotCalls++;
return slots;
}
@override
Future<Place> 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<void>.delayed(answer.$1);
return answer.$2;
}
// ---- everything the interface needs but these tests do not exercise ------
@override
Future<OtpChallenge> sendOtp(String identifier) async =>
OtpChallenge.unspecified;
@override
Future<OtpChallenge> signUp({
required String name,
required String phone,
String? email,
}) async => OtpChallenge.unspecified;
@override
Future<Customer> verifyOtp(
String identifier,
String code, {
String? name,
}) async => Customer(
id: 'c1',
name: name ?? 'Joe',
phone: identifier,
email: '',
);
@override
Future<List<ServiceArea>> getServiceableStates() async => const [];
@override
Future<List<District>> 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<List<Place>> searchPlaces(
String query, {
double? lat,
double? lng,
}) async => const [];
@override
Future<FareEstimate> estimateFare({
required Place pickup,
required List<DestinationGroup> destinations,
}) async => const FareEstimate(min: 49, max: 64, paymentMethod: 'UPI');
@override
Future<void> cancelBooking(String reference, String? reason) async {}
@override
@override
Future<Booking?> getBooking(String reference) async => null;
@override
bool isCancellable(JourneyStage stage) =>
stage.index < JourneyStage.arrived.index;
final List<(String, JourneyStage)> stageCalls = [];
@override
Future<void> 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('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('the test environment is a bypass, and says so out loud', () {
// The Miler app shipped a flag that swapped authentication for a fake
// rider on any four digits, and said nothing on screen. The rule that
// replaces it: a build that did not sign in must name it. Under
// FLUTTER_TEST the dev data is on, so it must read as bypassed and the
// environment line must carry the DEV DATA marker.
expect(AppConfig.useDevData, isTrue, reason: 'dev data is on under test');
expect(AppConfig.authBypassed, isTrue);
expect(AppConfig.describe, contains('DEV DATA'));
});
test('the auto-login default never reaches the tests', () {
// Dev data now defaults on in a debug build, and with it the login
// screen is skipped — one `flutter run` lands on Home, because staging
// has no SMS gateway to send a code through.
//
// The widget tests must keep walking that entrance anyway: they cover the
// phone field, the code screen, and the guard that refuses to open the
// code screen when nothing was sent. A session waiting for them at launch
// would delete all of it and nothing would fail, so [devAutoLogin] is
// switched off under FLUTTER_TEST on purpose. This is that promise.
expect(AppConfig.devAutoLogin, isFalse);
});
// The guarantee that matters cannot be asserted from a debug test — every
// switch is `!kReleaseMode`, so `useDevData`, `devAutoLogin`, `hasDevToken`
// and `allowStageOverride` are all forced 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<void>.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<ApiException>()));
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<ApiException>()));
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 = <String, dynamic>{
'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<String, dynamic>.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<String, dynamic>.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,
});
// The contract carries the recipient flat on the destination, beside the
// codes, and spells a dropped pin `latitude`/`longitude`.
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,
'recipientName': 'Meera S',
'building': '12/A',
'latitude': 13.085,
'longitude': 80.21,
});
});
test('a PATCH sends nulls, because null is how a field is cleared', () {
final details = DeliveryDetails(street: '12th Main');
final patch = details.toPatchJson();
expect(patch['street'], '12th Main');
expect(patch.containsKey('landmark'), isTrue);
expect(patch['landmark'], isNull);
expect(patch.containsKey('latitude'), isTrue);
});
});
}