Files
doormile_customer_app/test/live_api_wire_test.dart
Thiru-tenext 86b6af48c2 Redesign on the Stitch reference, in Plus Jakarta Sans
Four passes, and the shape they landed on.

The type face is Plus Jakarta Sans (variable, wght 200-800), which brings a
fix with it: it carries the rupee glyph and Switzer does not, so prices stop
being set in Geist Mono to work around a missing character. Mono stays where
it is earned - references and phone numbers, read digit by digit.

Surfaces lift rather than outline. Cards carry two very soft shadow layers
instead of a hairline, because eight outlined boxes down a screen read as a
wireframe. The tab bar floats as a pill again for the same reason it was
right to: it is now the same kind of object as everything above it.

Screens:

* Home is the greeting, the address, the sphere and one card. The card lost
  its progress bar - a filling line says "wait", and a parcel two days into
  a journey is not something anyone is waiting through - and gained the size
  that buys.
* Orders cards are four bands: identity, destination, route, and whatever is
  happening right now. Plus a search field, because the list is the archive.
* Tracking leads with the state at display size, then TRIP MILESTONES with a
  step counter, then the courier.
* Review is a route thread over two particular cards.
* Account opens on the person: avatar, name, and two counted figures.

Three real bugs the redesign surfaced:

* Quick dispatch handed `loadCities()` straight to a FutureBuilder, so the
  catalogue was refetched on every rebuild and Home never settled.
* Order cards showed the whole visit's weight on one destination's row -
  somebody else's parcel. Per group now, and only once actually weighed.
* The pickup window was printed beside "In transit", where it reads as a
  delivery time nobody promised.

Nothing invented. The reference shows EXPRESS PRIORITY, CARBON OFFSET,
CONCIERGE ELITE and hub-to-hub routing; this backend sends none of them, so
they are absent rather than mocked up.

flutter analyze: clean. flutter test: 88 passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
2026-09-24 11:08:25 +05:30

307 lines
10 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 sends the contract shape, flat and with a contact', () 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);
expect(pickup['contactName'], 'Alex Kumar');
expect(pickup['contactPhone'], '+919876543210');
final destination =
(body['destinations'] as List).single as Map<String, dynamic>;
expect(destination['recipientName'], 'Priya S');
expect(destination['building'], '12/A');
// Flat, not nested under `details`.
expect(destination.containsKey('details'), isFalse);
// And the note is not repeated here — it is the visit's `remarks`.
expect(destination.containsKey('instructions'), isFalse);
// Per-destination instructions become the visit's one `remarks` line.
expect(body['remarks'], 'Handle with care');
// 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);
});
}