── The full-address path was reaching the Miler empty ──
`DestinationGroup.toBookingJson` spread its details FLAT across the
destination. The contract nests them under `details{}`, and a destination
carrying keys the server does not recognise is accepted without a word — so
every building number, street, landmark, recipient name, recipient phone and
pin a customer typed was written, answered 201, and thrown away. The Miler
arrived with a district.
Four more on the same call. The destination pin spelled `latitude`/`longitude`
— the same spelling that answered 422 unserviceable for months on the pickup
before it was fixed there and missed here. A PATCH that sent `null` to clear a
field, with a comment saying so, when the server writes only non-nil values, so
a landmark could be added and never removed. Per-destination `instructions`
folded into the visit's one `remarks` line on the belief the contract had no
per-destination note; it has one. And `contactName`/`contactPhone` on the
pickup object, which the create contract has no room for and drops.
The fix ships unverified, deliberately. If `details{}` is also the wrong shape
the fields drop exactly as they do today — it cannot be worse, and holding it
costs every full-address booking in the meantime. docs/BACKEND_CHANGES.md asks
for the confirmation; tool/verify_booking.sh runs it in one command.
── Who the Miler rings ──
One number reaches the rider and it is the account's: `GET /miler/bookings`
returns a single `customerphone`, verified against production and written down
in the rider app's own stop_contact.dart. So "Someone else is handing it over?"
was collecting a number that reached nobody.
Review now shows the number that will actually be dialled, and the handover
person travels in `remarks` with a name, labelled for whoever reads it. Both
screens say plainly that the rider's call button still dials the account —
better than letting somebody hand their parcel to a neighbour believing
otherwise.
── Account's rows led nowhere ──
Two had no `onTap` at all — a chevron pointing at a page that did not exist —
and three answered with a toast. Five rows making a promise, one keeping it.
Notifications, Payment, Help and About are real screens now, written to one
rule: say only what is true of this app today. There is no notification
endpoint, no stored payment instrument and no push SDK wired in, so none of
them pretends to manage any of that. Support shows no contact block at all
rather than a number that rings nowhere — AppConfig carries the fields empty
until somebody fills them in.
── ONE TOUCH is one sheet ──
It was two in sequence with a dismissal between them, and the destination step
made you open a state to see any city — two levels of navigation for something
its own search already flattened. One flat list headed by state, which is also
the answer to "where do you deliver?", and one surface that changes its
question instead of closing so another can open.
Home says the reach in a line, and it needed two fixes to appear at all:
`cachedCities` walked closed states looking for districts that are only fetched
for open ones, and `loadCities` filled two caches while notifying nobody.
── Sending a second parcel ──
`maxDestinations` is 1 in production, so two parcels for two places means
booking twice — and that cost the whole flow twice, re-answering a door the
customer had not moved from. `startBookingFrom` carries the door, carries the
destination only when asked, and never carries the window: a slot fills up, and
a second booking pinned to one that is now full is refused at confirm with
nothing the customer can act on.
Review also says why there is no "add another destination", so a cap reads as a
limit rather than a missing button.
── Bundle ──
pubspec named its images one by one. Declaring `assets/images/` as a folder
shipped a 974 KB launcher-icon master to every customer for a file no code
opens.
327 lines
11 KiB
Dart
327 lines
11 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:doormile_cx/data/api_client.dart';
|
|
import 'package:doormile_cx/data/api_exception.dart';
|
|
import 'package:doormile_cx/data/live_doormile_api.dart';
|
|
import 'package:doormile_cx/data/models.dart';
|
|
import 'package:doormile_cx/data/session_store.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:http/testing.dart';
|
|
|
|
/// What [LiveDoormileApi] actually puts on the wire, checked field by field
|
|
/// against the customer API document.
|
|
///
|
|
/// These are not parser tests. Every one of them exists because a plausible,
|
|
/// readable, wrong spelling — `otp` for `code`, `latitude` for `lat`, `min` for
|
|
/// `minRupees` — costs a whole feature at runtime and nothing at compile time.
|
|
///
|
|
/// Where the written contract and the running server disagree, these tests
|
|
/// follow the server. The `otp`/`code` case below is why.
|
|
|
|
class _Sent {
|
|
http.Request? last;
|
|
Map<String, dynamic> get body =>
|
|
jsonDecode(last!.body) as Map<String, dynamic>;
|
|
}
|
|
|
|
({LiveDoormileApi api, _Sent sent}) _api(Object responseData) {
|
|
final sent = _Sent();
|
|
final http.Client transport = MockClient((request) async {
|
|
sent.last = request;
|
|
return http.Response(
|
|
jsonEncode({'success': true, 'data': responseData}),
|
|
200,
|
|
headers: {'content-type': 'application/json'},
|
|
);
|
|
});
|
|
return (
|
|
api: LiveDoormileApi(
|
|
client: ApiClient(httpClient: transport, sessions: MemorySessionStore()),
|
|
),
|
|
sent: sent,
|
|
);
|
|
}
|
|
|
|
void main() {
|
|
// ── This test used to assert the opposite, and that is why the bug shipped ──
|
|
//
|
|
// The written contract names this field `otp`. The server does not:
|
|
// CxVerifyOtp reads `json:"code"` and treats a body without it as an empty
|
|
// code, answering 400 "Enter the code we sent you" to every sign-in — with
|
|
// any code, correct or not. Verified against production: `otp` gets that 400,
|
|
// `code` gets 401 "That code did not match" for a wrong code, and a session
|
|
// for a right one.
|
|
//
|
|
// The document was wrong and this test agreed with it, so nothing failed
|
|
// until a human tried to log in. The assertion is inverted deliberately —
|
|
// the server is the contract.
|
|
test('OTP verification sends `code` — the field the server actually reads', () async {
|
|
final t = _api({
|
|
'accessToken': 'a',
|
|
'refreshToken': 'r',
|
|
'expiresIn': 3600,
|
|
'customer': {'id': 1042, 'name': 'Alex Kumar', 'phone': '+919876543210'},
|
|
});
|
|
|
|
final customer = await t.api.verifyOtp('9876543210', '1234');
|
|
|
|
expect(t.sent.last!.url.path, endsWith('/customer/auth/otp/verify'));
|
|
expect(t.sent.body['code'], '1234');
|
|
expect(t.sent.body['identifier'], '+919876543210');
|
|
expect(
|
|
t.sent.body.containsKey('otp'),
|
|
isFalse,
|
|
reason: 'the server ignores `otp` and reads the body as an empty code',
|
|
);
|
|
expect(t.sent.last!.headers['Idempotency-Key'], isNotNull);
|
|
expect(customer.name, 'Alex Kumar');
|
|
});
|
|
|
|
test('a fare estimate prices packages, and reads the rupee band back', () async {
|
|
final t = _api({
|
|
'minRupees': 240,
|
|
'maxRupees': 310,
|
|
'routeKm': 348.5,
|
|
'breakdown': {'baseFare': 180, 'additionalStopsUplift': 60},
|
|
});
|
|
|
|
final fare = await t.api.estimateFare(
|
|
pickup: const Place(title: 'Home', sub: '', lat: 13.0827, lng: 80.2707),
|
|
destinations: [
|
|
DestinationGroup(
|
|
destination: Destination(stateCode: 'TN', districtCode: 'CHN'),
|
|
packageCount: 2,
|
|
),
|
|
],
|
|
);
|
|
|
|
// ── `lat`/`lng`, not `latitude`/`longitude` ──
|
|
//
|
|
// The written contract says the long spelling and this app sent it. The
|
|
// server reads the short one, so it saw a pickup with no coordinates, could
|
|
// not place it in a serviceable area, and answered every booking with
|
|
// 422 `unserviceable`. Verified against production on 2026-09-23 one
|
|
// request apart — same pickup, same slot: long spelling 422, short
|
|
// spelling 201 and booking DM-252803.
|
|
final pickup = t.sent.body['pickup'] as Map<String, dynamic>;
|
|
expect(pickup['lat'], 13.0827);
|
|
expect(pickup['lng'], 80.2707);
|
|
expect(pickup.containsKey('latitude'), isFalse);
|
|
|
|
final destination =
|
|
(t.sent.body['destinations'] as List).single as Map<String, dynamic>;
|
|
expect(destination['stateCode'], 'TN');
|
|
// One entry per package, and no weight on any of them — the customer is
|
|
// never asked what a parcel weighs.
|
|
expect((destination['packages'] as List).length, 2);
|
|
|
|
expect(fare.min, 240);
|
|
expect(fare.max, 310);
|
|
expect(fare.routeKm, 348.5);
|
|
});
|
|
|
|
test('a booking nests its destination details', () async {
|
|
final t = _api({
|
|
'reference': 'DM-482913',
|
|
'stage': 'booked',
|
|
'status': 'active',
|
|
'cancellable': true,
|
|
'createdAt': 1788775499000,
|
|
'slotId': 'slot_20260908_t2',
|
|
'pickup': {
|
|
'title': 'Home',
|
|
'sub': 'Flat 4B, Green Towers',
|
|
'latitude': 13.0827,
|
|
'longitude': 80.2707,
|
|
},
|
|
'destinations': [
|
|
{
|
|
'index': 0,
|
|
'stateName': 'Tamil Nadu',
|
|
'districtName': 'Chennai',
|
|
'packageCount': 1,
|
|
'codAmount': 450,
|
|
'trackingId': null,
|
|
'stage': null,
|
|
},
|
|
],
|
|
});
|
|
|
|
await t.api.client.adoptSession(
|
|
Session(
|
|
accessToken: 'a',
|
|
refreshToken: 'r',
|
|
expiresAt: DateTime.now().add(const Duration(hours: 1)),
|
|
customer: const Customer(
|
|
id: '1042',
|
|
name: 'Alex Kumar',
|
|
phone: '+919876543210',
|
|
email: '',
|
|
),
|
|
),
|
|
);
|
|
|
|
final group = DestinationGroup(
|
|
destination: Destination(stateCode: 'TN', districtCode: 'CHN'),
|
|
);
|
|
group.details.update(
|
|
recipientName: 'Priya S',
|
|
building: '12/A',
|
|
street: 'MG Road',
|
|
instructions: 'Handle with care',
|
|
);
|
|
|
|
final booking = await t.api.createBooking(
|
|
pickup: const Place(
|
|
title: 'Home',
|
|
sub: 'Flat 4B, Green Towers',
|
|
lat: 13.0827,
|
|
lng: 80.2707,
|
|
),
|
|
destinations: [group],
|
|
slotId: 'slot_20260908_t2',
|
|
);
|
|
|
|
final body = t.sent.body;
|
|
final pickup = body['pickup'] as Map<String, dynamic>;
|
|
expect(pickup['lat'], 13.0827);
|
|
|
|
// ── The pickup carries no contact ──
|
|
//
|
|
// It used to send `contactName` and `contactPhone` here and this test
|
|
// asserted them. The create contract's pickup is `{title, sub, lat, lng}`;
|
|
// extra keys are dropped without an error, and the rider's number is
|
|
// derived by the backend from the account. Two fields written on every
|
|
// booking and read by nobody.
|
|
expect(pickup.containsKey('contactName'), isFalse);
|
|
expect(pickup.containsKey('contactPhone'), isFalse);
|
|
|
|
final destination =
|
|
(body['destinations'] as List).single as Map<String, dynamic>;
|
|
|
|
// ── Nested, not flat ──
|
|
//
|
|
// The previous shape spread these across the destination and this test
|
|
// asserted `details` was absent. The contract nests them, and a
|
|
// destination's extra keys are dropped silently — so the whole
|
|
// full-address path was being accepted with a 201 and thrown away.
|
|
//
|
|
// NOT yet confirmed against the running server. The `lat`/`lng` fix was
|
|
// proven with two identical requests one apart; this deserves the same
|
|
// before it is trusted in production.
|
|
final details = destination['details'] as Map<String, dynamic>;
|
|
expect(details['recipientName'], 'Priya S');
|
|
expect(details['building'], '12/A');
|
|
expect(details['street'], 'MG Road');
|
|
// The note has its own field per destination; it is not the visit's line.
|
|
expect(details['instructions'], 'Handle with care');
|
|
expect(destination.containsKey('recipientName'), isFalse);
|
|
|
|
// `remarks` is now only the handover person, and there is none here.
|
|
expect(body.containsKey('remarks'), isFalse);
|
|
|
|
// And the response — which names the state and district but sends no
|
|
// codes — must still render as a destination.
|
|
expect(booking.reference, 'DM-482913');
|
|
expect(booking.pickup.lat, 13.0827);
|
|
final read = booking.destinations.single;
|
|
expect(read.destination.label, 'Chennai, Tamil Nadu');
|
|
expect(read.index, 0);
|
|
expect(read.codAmount, 450);
|
|
expect(read.trackingId, isNull);
|
|
});
|
|
|
|
test('the cancel window closes when the Miler arrives', () {
|
|
final t = _api(const {});
|
|
expect(t.api.isCancellable(JourneyStage.onTheWay), isTrue);
|
|
expect(t.api.isCancellable(JourneyStage.arrived), isFalse);
|
|
expect(t.api.isCancellable(JourneyStage.pickedUp), isFalse);
|
|
});
|
|
|
|
test('booking limits carry the COD cap', () {
|
|
const limits = BookingLimits();
|
|
expect(limits.allowsCod, isFalse);
|
|
expect(
|
|
BookingLimits.fromJson(const {
|
|
'maxDestinations': 1,
|
|
'maxPackages': 20,
|
|
'maxCodAmount': 5000,
|
|
}).maxCodAmount,
|
|
5000,
|
|
);
|
|
});
|
|
|
|
group('error codes', () {
|
|
/// Drives a real failure through [ApiClient], because the translation that
|
|
/// matters happens while the response is being decoded.
|
|
Future<ApiException> failing(int status, String code) async {
|
|
final client = ApiClient(
|
|
httpClient: MockClient(
|
|
(_) async => http.Response(
|
|
jsonEncode({
|
|
'success': false,
|
|
'error': {'code': code, 'message': 'Something the server said'},
|
|
}),
|
|
status,
|
|
headers: {'content-type': 'application/json'},
|
|
),
|
|
),
|
|
sessions: MemorySessionStore(),
|
|
);
|
|
try {
|
|
await client.get('/bookings', authenticated: false);
|
|
} on ApiException catch (e) {
|
|
return e;
|
|
}
|
|
fail('expected $code to throw');
|
|
}
|
|
|
|
test('the contract spells its codes in capitals; the client translates', () async {
|
|
expect((await failing(401, 'UNAUTHORIZED')).isAuthFailure, isTrue);
|
|
expect((await failing(409, 'SLOT_CAPACITY_FULL')).needsFreshSlots, isTrue);
|
|
expect((await failing(400, 'SLOT_EXPIRED')).needsFreshSlots, isTrue);
|
|
expect(
|
|
(await failing(409, 'BOOKING_NOT_CANCELLABLE')).code,
|
|
ApiException.notCancellable,
|
|
);
|
|
expect(
|
|
(await failing(422, 'UNSERVICEABLE_PINCODE')).code,
|
|
ApiException.unserviceable,
|
|
);
|
|
expect((await failing(429, 'RATE_LIMITED')).isTransient, isTrue);
|
|
// The raw code is kept for the log line and the support report.
|
|
expect((await failing(401, 'UNAUTHORIZED')).serverCode, 'UNAUTHORIZED');
|
|
// An unknown code still lands somewhere useful: the HTTP status.
|
|
expect((await failing(404, 'NO_SUCH_THING')).code, ApiException.notFound);
|
|
});
|
|
});
|
|
|
|
test('a paged list is read through its wrapper as well as beside it', () async {
|
|
final client = ApiClient(
|
|
httpClient: MockClient(
|
|
(_) async => http.Response(
|
|
jsonEncode({
|
|
'success': true,
|
|
'data': {
|
|
'items': [
|
|
{'reference': 'DM-1'},
|
|
],
|
|
'nextCursor': 'cur_2',
|
|
'total': 9,
|
|
},
|
|
}),
|
|
200,
|
|
headers: {'content-type': 'application/json'},
|
|
),
|
|
),
|
|
sessions: MemorySessionStore(),
|
|
);
|
|
|
|
final page = await client.get('/bookings', authenticated: false);
|
|
expect(page.rows.single['reference'], 'DM-1');
|
|
expect(page.nextCursor, 'cur_2');
|
|
expect(page.total, 9);
|
|
});
|
|
}
|