Files
doormile_customer_app/test/api_integration_test.dart
Thiru-tenext 8757b16cf5 PIN sign-in, because the code could never arrive
── What was actually broken ──

The SMS gateway was switched off, and `POST /auth/otp/request` does not fail
when that happens: it still answers `sent: true`, still issues a valid 4-digit
code, and writes it to the **server log**. So the phone path walked customers
to a code screen for a code that could not arrive, and every digit they
eventually typed was wrong. The failure read to them as "I entered it wrong".

Phone sign-in is now a PIN, which needs no gateway.

── Email still sends codes, so email is untouched ──

Email OTP goes over SMTP and works. Deleting a working way in to tidy up a
broken one is a net loss for anyone with an email on their account, so "Use
email instead" and the code screen stay exactly as they were.
`login_otp_guard_test` moves to that path — the `sent: false` guard still
matters there, and that is now the only place it can fire.

── One screen, three entrances ──

`POST /auth/login` says which of them a number is before anything is asked, so
the app never guesses. Guessing is not cosmetic: offer "create a PIN" to a
returning customer and the server answers `pin_already_set` on a screen that
cannot succeed; offer "enter your PIN" to somebody who has never set one and
every attempt is wrong.

The separate sign-up screen is deleted rather than hidden. It asked for a name
and then sent an SMS code — a second entrance asking the same questions and
posting a letter that never lands. A new number now gives its name and PIN on
the same screen.

── A second sign-in path found a latent bug ──

`AppState.signIn` only started `refreshOrders`, and the OTP screen called
`detectPickupLocation` itself afterwards to make up the difference. That held
exactly as long as there was one sign-in screen. PIN sign-in did not know about
the extra call, so Home opened with no pickup and no serviceable cities.

The work belongs to signing in, not to whichever screen happened to be last, so
it moved into `signIn` and the OTP screen's copy is gone.

── What the screen deliberately does not do ──

It does not greet by name. `POST /auth/login` returns the account holder's
name, which tells anybody who types a number who owns it; the field is read but
never displayed, so it disappears quietly when the backend drops it.

It does not say whether the number or the PIN was wrong — the server answers
identically for both on purpose, and narrowing it here would turn sign-in into
a way of testing whether a number has an account.

"Forgot your PIN?" renders only when a support contact is configured. There is
no reset endpoint, so it can only point at a human — and telling somebody
locked out that help exists without saying where is worse than silence.

── The handover note does not reach the Miler ──

The app said "we pass this to your Miler as a note". It does not: `remarks`
reaches the admin console and stops, because the rider app reads a `notes`
field per stop that the backend never sends. A customer could hand their parcel
to a neighbour believing the Miler had been told. Both screens now say it is
recorded on the booking, and that the Miler still calls the account's number.

── Also ──

DmTextField gains `obscure`, and PinScreen carries a back button — without one
the only correction for a mistyped digit was killing the app.
2026-09-30 10:44:22 +05:30

758 lines
26 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 = {};
// PIN sign-in is not what this file exercises; these satisfy the interface.
@override
Future<PhoneCheck> checkPhone(String phone) async =>
PhoneCheck(phone: phone, registered: false, pinSet: false);
@override
Future<Customer> setPin({
required String phone,
required String pin,
String? name,
}) async => Customer(id: 'c', name: name ?? '', phone: phone, email: '');
@override
Future<Customer> verifyPin({
required String phone,
required String pin,
}) async => Customer(id: 'c', name: '', phone: phone, email: '');
@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? 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<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('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<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,
});
// ── 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});
});
});
}