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>
674 lines
22 KiB
Dart
674 lines
22 KiB
Dart
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);
|
||
});
|
||
});
|
||
}
|