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>
298 lines
9.8 KiB
Dart
298 lines
9.8 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`, `lat` for `latitude`, `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,
|
|
),
|
|
],
|
|
);
|
|
|
|
final pickup = t.sent.body['pickup'] as Map<String, dynamic>;
|
|
expect(pickup['latitude'], 13.0827);
|
|
expect(pickup['longitude'], 80.2707);
|
|
|
|
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['latitude'], 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);
|
|
});
|
|
}
|