Replace the customer app with Doormile CX
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>
432
test/api_client_test.dart
Normal file
@@ -0,0 +1,432 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:doormile_cx/data/api_client.dart';
|
||||
import 'package:doormile_cx/data/api_exception.dart';
|
||||
import 'package:doormile_cx/data/session_store.dart';
|
||||
import 'package:doormile_cx/data/models.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
|
||||
/// Records what the client actually put on the wire.
|
||||
class _Recorder {
|
||||
final List<http.BaseRequest> requests = [];
|
||||
}
|
||||
|
||||
MockClient _client(
|
||||
_Recorder log,
|
||||
http.Response Function(http.Request request, int callIndex) respond,
|
||||
) {
|
||||
var calls = 0;
|
||||
return MockClient((request) async {
|
||||
log.requests.add(request);
|
||||
return respond(request, calls++);
|
||||
});
|
||||
}
|
||||
|
||||
http.Response _ok(Object data, {Map<String, String>? headers}) => http.Response(
|
||||
jsonEncode({'success': true, 'data': data, 'message': ''}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json', ...?headers},
|
||||
);
|
||||
|
||||
http.Response _fail(int status, String code, String message) => http.Response(
|
||||
jsonEncode({
|
||||
'success': false,
|
||||
'message': message,
|
||||
'error': {'code': code},
|
||||
}),
|
||||
status,
|
||||
headers: {'content-type': 'application/json', 'x-request-id': 'req_42'},
|
||||
);
|
||||
|
||||
Session _session({
|
||||
String access = 'access-1',
|
||||
String refresh = 'refresh-1',
|
||||
Duration life = const Duration(hours: 1),
|
||||
}) => Session(
|
||||
accessToken: access,
|
||||
refreshToken: refresh,
|
||||
expiresAt: DateTime.now().add(life),
|
||||
);
|
||||
|
||||
void main() {
|
||||
group('envelope and headers', () {
|
||||
test('unwraps data and sends the client identity on every request', () async {
|
||||
final log = _Recorder();
|
||||
final client = ApiClient(
|
||||
httpClient: _client(log, (_, _) => _ok({'ok': true})),
|
||||
sessions: MemorySessionStore(),
|
||||
);
|
||||
|
||||
final response = await client.get('/config/booking-limits');
|
||||
|
||||
expect(response.map['ok'], isTrue);
|
||||
final sent = log.requests.single;
|
||||
expect(sent.headers['X-Client'], startsWith('doormile-cx/'));
|
||||
expect(sent.headers['X-Platform'], isNotEmpty);
|
||||
expect(sent.url.path, endsWith('/customer/config/booking-limits'));
|
||||
});
|
||||
|
||||
test('sends the bearer token when there is a session', () async {
|
||||
final log = _Recorder();
|
||||
final sessions = MemorySessionStore();
|
||||
await sessions.write(_session());
|
||||
final client = ApiClient(
|
||||
httpClient: _client(log, (_, _) => _ok(const {})),
|
||||
sessions: sessions,
|
||||
);
|
||||
|
||||
await client.get('/auth/me');
|
||||
|
||||
expect(log.requests.single.headers['Authorization'], 'Bearer access-1');
|
||||
});
|
||||
|
||||
test('carries the idempotency key given to it', () async {
|
||||
final log = _Recorder();
|
||||
final client = ApiClient(
|
||||
httpClient: _client(log, (_, _) => _ok(const {})),
|
||||
sessions: MemorySessionStore(),
|
||||
);
|
||||
|
||||
await client.post('/bookings', body: const {}, idempotencyKey: 'key-1');
|
||||
|
||||
expect(log.requests.single.headers['Idempotency-Key'], 'key-1');
|
||||
});
|
||||
|
||||
test('a fresh idempotency key is never the same twice', () {
|
||||
final client = ApiClient(
|
||||
httpClient: _client(_Recorder(), (_, _) => _ok(const {})),
|
||||
sessions: MemorySessionStore(),
|
||||
);
|
||||
final keys = List.generate(50, (_) => client.newIdempotencyKey());
|
||||
expect(keys.toSet().length, 50);
|
||||
});
|
||||
});
|
||||
|
||||
group('error mapping', () {
|
||||
test('maps every contract status onto its code', () async {
|
||||
const cases = {
|
||||
400: ApiException.invalid,
|
||||
401: ApiException.unauthorized,
|
||||
403: ApiException.forbidden,
|
||||
404: ApiException.notFound,
|
||||
409: ApiException.conflict,
|
||||
422: ApiException.unserviceable,
|
||||
429: ApiException.rateLimited,
|
||||
500: ApiException.serverError,
|
||||
};
|
||||
|
||||
for (final entry in cases.entries) {
|
||||
final client = ApiClient(
|
||||
httpClient: MockClient(
|
||||
(_) async => http.Response(
|
||||
jsonEncode({'success': false, 'message': 'nope'}),
|
||||
entry.key,
|
||||
),
|
||||
),
|
||||
sessions: MemorySessionStore(),
|
||||
);
|
||||
await expectLater(
|
||||
// 401 without a session must not be retried as a refresh.
|
||||
client.get('/bookings', authenticated: false),
|
||||
throwsA(
|
||||
isA<ApiException>().having((e) => e.code, 'code', entry.value),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("the server's own code wins over the status default", () async {
|
||||
final client = ApiClient(
|
||||
httpClient: MockClient(
|
||||
(_) async => _fail(400, ApiException.invalidName, 'Enter your full name'),
|
||||
),
|
||||
sessions: MemorySessionStore(),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
client.post('/auth/signup', body: const {}, authenticated: false),
|
||||
throwsA(
|
||||
isA<ApiException>()
|
||||
.having((e) => e.code, 'code', ApiException.invalidName)
|
||||
.having((e) => e.message, 'message', 'Enter your full name')
|
||||
.having((e) => e.requestId, 'requestId', 'req_42'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('an HTML error page is a network failure, not a parse crash', () async {
|
||||
final client = ApiClient(
|
||||
httpClient: MockClient(
|
||||
(_) async => http.Response('<html>502 Bad Gateway</html>', 502),
|
||||
),
|
||||
sessions: MemorySessionStore(),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
client.get('/bookings', authenticated: false),
|
||||
throwsA(
|
||||
isA<ApiException>().having(
|
||||
(e) => e.code,
|
||||
'code',
|
||||
ApiException.serverError,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('a stale slot is recognised however the server phrases it', () {
|
||||
expect(
|
||||
ApiException(ApiException.conflict, 'That pickup window just filled up')
|
||||
.needsFreshSlots,
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
ApiException(
|
||||
ApiException.invalid,
|
||||
'That pickup time has passed — pick a new slot',
|
||||
).needsFreshSlots,
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
ApiException(ApiException.invalid, 'Enter your full name')
|
||||
.needsFreshSlots,
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('retries', () {
|
||||
test('retries a GET through a transient failure', () async {
|
||||
final log = _Recorder();
|
||||
final client = ApiClient(
|
||||
httpClient: _client(
|
||||
log,
|
||||
(_, call) =>
|
||||
call < 2 ? http.Response('', 503) : _ok(const {'ok': true}),
|
||||
),
|
||||
sessions: MemorySessionStore(),
|
||||
);
|
||||
|
||||
final response = await client.get('/serviceability/states');
|
||||
|
||||
expect(response.map['ok'], isTrue);
|
||||
expect(log.requests.length, 3);
|
||||
});
|
||||
|
||||
test('never replays a POST — a second booking is worse than an error', () async {
|
||||
final log = _Recorder();
|
||||
final client = ApiClient(
|
||||
httpClient: _client(log, (_, _) => http.Response('', 503)),
|
||||
sessions: MemorySessionStore(),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
client.post('/bookings', body: const {}),
|
||||
throwsA(isA<ApiException>()),
|
||||
);
|
||||
expect(log.requests.length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
group('ETag', () {
|
||||
test('replays the tag and reports a 304 rather than empty data', () async {
|
||||
final log = _Recorder();
|
||||
final client = ApiClient(
|
||||
httpClient: _client(
|
||||
log,
|
||||
(_, call) => call == 0
|
||||
? _ok(const [], headers: {'etag': 'W/"v1"'})
|
||||
: http.Response('', 304, headers: {'etag': 'W/"v1"'}),
|
||||
),
|
||||
sessions: MemorySessionStore(),
|
||||
);
|
||||
|
||||
final first = await client.get('/serviceability/states');
|
||||
expect(first.etag, 'W/"v1"');
|
||||
expect(first.notModified, isFalse);
|
||||
|
||||
final second = await client.get(
|
||||
'/serviceability/states',
|
||||
ifNoneMatch: first.etag,
|
||||
);
|
||||
expect(second.notModified, isTrue);
|
||||
expect(log.requests.last.headers['If-None-Match'], 'W/"v1"');
|
||||
});
|
||||
});
|
||||
|
||||
group('keyset pagination', () {
|
||||
test('carries the cursor out of the envelope', () async {
|
||||
final client = ApiClient(
|
||||
httpClient: MockClient(
|
||||
(_) async => http.Response(
|
||||
jsonEncode({
|
||||
'success': true,
|
||||
'data': [
|
||||
{'reference': 'DM-1'},
|
||||
],
|
||||
'total': 42,
|
||||
'nextCursor': 'cur_2',
|
||||
}),
|
||||
200,
|
||||
),
|
||||
),
|
||||
sessions: MemorySessionStore(),
|
||||
);
|
||||
|
||||
final page = await client.get('/bookings');
|
||||
|
||||
expect(page.rows.single['reference'], 'DM-1');
|
||||
expect(page.total, 42);
|
||||
expect(page.nextCursor, 'cur_2');
|
||||
});
|
||||
});
|
||||
|
||||
group('session', () {
|
||||
test('refreshes on 401, persists the rotated token, retries once', () async {
|
||||
final log = _Recorder();
|
||||
final sessions = MemorySessionStore();
|
||||
await sessions.write(_session());
|
||||
|
||||
final client = ApiClient(
|
||||
httpClient: _client(log, (request, _) {
|
||||
if (request.url.path.endsWith('/auth/refresh')) {
|
||||
return _ok(const {
|
||||
'accessToken': 'access-2',
|
||||
'refreshToken': 'refresh-2',
|
||||
'expiresIn': 3600,
|
||||
});
|
||||
}
|
||||
final auth = request.headers['Authorization'];
|
||||
return auth == 'Bearer access-2'
|
||||
? _ok(const {'ok': true})
|
||||
: _fail(401, ApiException.unauthorized, 'Please sign in again');
|
||||
}),
|
||||
sessions: sessions,
|
||||
);
|
||||
|
||||
final response = await client.get('/bookings');
|
||||
|
||||
expect(response.map['ok'], isTrue);
|
||||
// The rotated pair is the only one that still works, so it must be the
|
||||
// one on disk before anything else uses it.
|
||||
final stored = await sessions.read();
|
||||
expect(stored!.accessToken, 'access-2');
|
||||
expect(stored.refreshToken, 'refresh-2');
|
||||
});
|
||||
|
||||
test('an expired access token refreshes before the call, not after', () async {
|
||||
final log = _Recorder();
|
||||
final sessions = MemorySessionStore();
|
||||
await sessions.write(_session(life: const Duration(seconds: 5)));
|
||||
|
||||
final client = ApiClient(
|
||||
httpClient: _client(log, (request, _) {
|
||||
if (request.url.path.endsWith('/auth/refresh')) {
|
||||
return _ok(const {
|
||||
'accessToken': 'access-2',
|
||||
'refreshToken': 'refresh-2',
|
||||
'expiresIn': 3600,
|
||||
});
|
||||
}
|
||||
return _ok(const {'ok': true});
|
||||
}),
|
||||
sessions: sessions,
|
||||
);
|
||||
|
||||
await client.get('/bookings');
|
||||
|
||||
// Refresh first, then the real call — and no 401 spent discovering it.
|
||||
expect(log.requests.first.url.path, endsWith('/auth/refresh'));
|
||||
expect(log.requests.last.headers['Authorization'], 'Bearer access-2');
|
||||
});
|
||||
|
||||
test('a dead refresh chain clears the session and reports it once', () async {
|
||||
var lost = 0;
|
||||
final sessions = MemorySessionStore();
|
||||
await sessions.write(_session());
|
||||
|
||||
final client = ApiClient(
|
||||
httpClient: MockClient(
|
||||
(_) async => _fail(401, ApiException.unauthorized, 'Please sign in again'),
|
||||
),
|
||||
sessions: sessions,
|
||||
)..onSessionLost = () => lost++;
|
||||
|
||||
await expectLater(
|
||||
client.get('/bookings'),
|
||||
throwsA(isA<ApiException>().having((e) => e.isAuthFailure, 'auth', isTrue)),
|
||||
);
|
||||
|
||||
expect(await sessions.read(), isNull);
|
||||
expect(lost, 1);
|
||||
});
|
||||
|
||||
test('concurrent 401s produce one refresh, not one each', () async {
|
||||
final log = _Recorder();
|
||||
final sessions = MemorySessionStore();
|
||||
await sessions.write(_session());
|
||||
|
||||
final client = ApiClient(
|
||||
httpClient: _client(log, (request, _) {
|
||||
if (request.url.path.endsWith('/auth/refresh')) {
|
||||
return _ok(const {
|
||||
'accessToken': 'access-2',
|
||||
'refreshToken': 'refresh-2',
|
||||
'expiresIn': 3600,
|
||||
});
|
||||
}
|
||||
return request.headers['Authorization'] == 'Bearer access-2'
|
||||
? _ok(const {'ok': true})
|
||||
: _fail(401, ApiException.unauthorized, 'Please sign in again');
|
||||
}),
|
||||
sessions: sessions,
|
||||
);
|
||||
|
||||
await Future.wait([
|
||||
client.get('/bookings'),
|
||||
client.get('/auth/me'),
|
||||
client.get('/pickup-slots'),
|
||||
]);
|
||||
|
||||
final refreshes = log.requests
|
||||
.where((r) => r.url.path.endsWith('/auth/refresh'))
|
||||
.length;
|
||||
// A rotated refresh token is single-use: a second call would replay a
|
||||
// token the server has already revoked and kill the whole chain.
|
||||
expect(refreshes, 1);
|
||||
});
|
||||
|
||||
test('a session survives a round trip through the store', () async {
|
||||
final store = MemorySessionStore();
|
||||
await store.write(
|
||||
Session(
|
||||
accessToken: 'a',
|
||||
refreshToken: 'r',
|
||||
expiresAt: DateTime.fromMillisecondsSinceEpoch(1757056800000),
|
||||
customer: const Customer(
|
||||
id: 'cust_1',
|
||||
name: 'Joe Oommen',
|
||||
phone: '+919876543210',
|
||||
email: '',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final restored = Session.fromJson(
|
||||
jsonDecode(jsonEncode((await store.read())!.toJson()))
|
||||
as Map<String, dynamic>,
|
||||
);
|
||||
|
||||
expect(restored!.accessToken, 'a');
|
||||
expect(restored.refreshToken, 'r');
|
||||
expect(restored.customer!.name, 'Joe Oommen');
|
||||
});
|
||||
|
||||
test('an expiring session is treated as expired a minute early', () {
|
||||
expect(_session(life: const Duration(seconds: 30)).isExpired, isTrue);
|
||||
expect(_session(life: const Duration(minutes: 30)).isExpired, isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
673
test/api_integration_test.dart
Normal file
@@ -0,0 +1,673 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
799
test/booking_flow_test.dart
Normal file
@@ -0,0 +1,799 @@
|
||||
import 'dart:async';
|
||||
|
||||
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/main.dart';
|
||||
import 'package:doormile_cx/state/app_state.dart';
|
||||
import 'package:doormile_cx/ui/widgets/buttons.dart';
|
||||
import 'package:doormile_cx/ui/widgets/map_panel.dart';
|
||||
import 'package:doormile_cx/ui/widgets/chrome.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:doormile_cx/data/dev_doormile_api.dart';
|
||||
|
||||
/// The app runs continuous animations (radar, live dots, map pulse, skeletons),
|
||||
/// so `pumpAndSettle` would never return. Pump fixed slices instead.
|
||||
Future<void> settle(WidgetTester tester, [int ms = 800]) async {
|
||||
await tester.pump();
|
||||
await tester.pump(Duration(milliseconds: ms));
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
}
|
||||
|
||||
/// Drains toast and fare-estimate timers so a test ends with nothing pending.
|
||||
Future<void> drainToasts(WidgetTester tester) async {
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
}
|
||||
|
||||
|
||||
/// The street line currently shown under the pickup map. Empty while the pin
|
||||
/// is still being resolved, which is itself the thing worth asserting.
|
||||
String pickupAddress(WidgetTester tester) {
|
||||
final title = find.byKey(const ValueKey('pickupAddressTitle'));
|
||||
return title.evaluate().isEmpty
|
||||
? ''
|
||||
: tester.widget<Text>(title).data ?? '';
|
||||
}
|
||||
|
||||
Future<void> signIn(WidgetTester tester, {AppState? state}) async {
|
||||
// A tall surface so whole screens fit without scrolling in the harness.
|
||||
tester.view.physicalSize = const Size(1230, 9000);
|
||||
tester.view.devicePixelRatio = 3.0;
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
await tester.pumpWidget(DoormileApp(initialState: state));
|
||||
await settle(tester);
|
||||
|
||||
await tester.enterText(find.byType(TextField).first, '9876543210');
|
||||
// The CTA only enables once the field rebuilds with a valid number.
|
||||
await settle(tester, 200);
|
||||
await tester.tap(find.text('Continue'));
|
||||
await settle(tester);
|
||||
|
||||
// The OTP screen verifies as soon as the fourth digit lands.
|
||||
final boxes = find.byType(TextField);
|
||||
for (var i = 0; i < 4; i++) {
|
||||
await tester.enterText(boxes.at(i), '1');
|
||||
await tester.pump();
|
||||
}
|
||||
await settle(tester);
|
||||
}
|
||||
|
||||
/// Moves a booking on the **server**, then lets the app read it back.
|
||||
///
|
||||
/// This is the only way a stage changes now: the app has no local stage
|
||||
/// machine, so a test cannot step a booking without the backend doing it
|
||||
/// first. Fire-and-pump rather than await — the fake answers on a delay the
|
||||
/// test clock only advances while pumping.
|
||||
Future<void> serverAdvance(
|
||||
WidgetTester tester,
|
||||
AppState state,
|
||||
String reference,
|
||||
JourneyStage stage,
|
||||
) async {
|
||||
final booking = state.byReference(reference)!;
|
||||
unawaited(state.advanceStage(booking, stage));
|
||||
await settle(tester, 1400);
|
||||
}
|
||||
|
||||
/// Home ▸ BOOK ▸ confirm the pin ▸ destination step.
|
||||
Future<void> openDestination(WidgetTester tester) async {
|
||||
await tester.tap(find.text('Start booking'));
|
||||
await settle(tester);
|
||||
await tester.tap(find.text('Next'));
|
||||
await settle(tester);
|
||||
}
|
||||
|
||||
/// State chips scroll horizontally; drag later ones fully into view first.
|
||||
Future<void> tapState(WidgetTester tester, String name) async {
|
||||
final width = tester.view.physicalSize.width / tester.view.devicePixelRatio;
|
||||
final chips = find
|
||||
.ancestor(of: find.text('Tamil Nadu'), matching: find.byType(Scrollable))
|
||||
.first;
|
||||
|
||||
for (var i = 0; i < 8; i++) {
|
||||
final finder = find.text(name);
|
||||
if (finder.evaluate().isNotEmpty) {
|
||||
final rect = tester.getRect(finder);
|
||||
if (rect.left >= 0 && rect.right <= width) break;
|
||||
}
|
||||
await tester.drag(chips, const Offset(-160, 0));
|
||||
await tester.pump();
|
||||
}
|
||||
await tester.tap(find.text(name));
|
||||
await settle(tester);
|
||||
}
|
||||
|
||||
Future<void> pickPlace(WidgetTester tester, String state, String district) async {
|
||||
await tapState(tester, state);
|
||||
await tester.tap(find.text(district));
|
||||
await settle(tester);
|
||||
}
|
||||
|
||||
/// The fake standing in for the backend in this file.
|
||||
///
|
||||
/// A fresh one per test, installed as the singleton so the widgets under test
|
||||
/// reach it exactly as they would reach the real API. It is the **only** source
|
||||
/// of bookings here: nothing plants one in [AppState].
|
||||
late DevDoormileApi api;
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
api = DevDoormileApi();
|
||||
DoormileApi.overrideInstance(api);
|
||||
|
||||
// Widget tests run on a fake clock a real platform-channel reply never
|
||||
// reaches, so the device GPS is replaced with a fixed fix. Without this the
|
||||
// pickup screen would sit on "Getting address…" forever.
|
||||
LocationService.instance = FixedLocationService();
|
||||
});
|
||||
|
||||
tearDown(() => DoormileApi.overrideInstance(null));
|
||||
|
||||
testWidgets('signs in and lands on Home', (tester) async {
|
||||
await signIn(tester);
|
||||
|
||||
expect(find.text('Send a parcel'), findsOneWidget);
|
||||
expect(find.text('Start booking'), findsOneWidget);
|
||||
// The seeded active booking surfaces on Home.
|
||||
expect(find.text('Miler assigned'), findsOneWidget);
|
||||
await drainToasts(tester);
|
||||
});
|
||||
|
||||
testWidgets('dragging the map re-resolves the pickup address',
|
||||
(tester) async {
|
||||
await signIn(tester);
|
||||
await tester.tap(find.text('Start booking'));
|
||||
await settle(tester);
|
||||
|
||||
// The pin resolved to a street, not a placeholder.
|
||||
expect(find.text('Getting address…'), findsNothing);
|
||||
final address = pickupAddress(tester);
|
||||
expect(address, isNotEmpty);
|
||||
|
||||
// Move the map under the fixed pin, the way a customer corrects the door.
|
||||
await tester.drag(find.byType(DmMapPanel), const Offset(-90, -70));
|
||||
await tester.pump();
|
||||
|
||||
// The address goes quiet while the new coordinates are resolved…
|
||||
expect(find.text('Getting address…'), findsOneWidget);
|
||||
|
||||
// …and comes back as a different place.
|
||||
await settle(tester, 900);
|
||||
expect(find.text('Getting address…'), findsNothing);
|
||||
expect(pickupAddress(tester), isNot(address));
|
||||
|
||||
// Which is what Next now carries forward.
|
||||
await tester.tap(find.text('Next'));
|
||||
await settle(tester);
|
||||
expect(find.text('Where are you sending it?'), findsOneWidget);
|
||||
await drainToasts(tester);
|
||||
});
|
||||
|
||||
testWidgets('GPS, map drag and search all write one pickup point',
|
||||
(tester) async {
|
||||
final state = AppState(
|
||||
location: FixedLocationService(lat: 11.0183, lng: 76.9725),
|
||||
);
|
||||
await signIn(tester, state: state);
|
||||
await tester.tap(find.text('Start booking'));
|
||||
await settle(tester);
|
||||
|
||||
DmMapPanel map() => tester.widget<DmMapPanel>(find.byType(DmMapPanel));
|
||||
|
||||
// 1 — the device fix. Address and coordinates land together.
|
||||
final located = state.pickup!;
|
||||
expect(state.hasPickupPoint, isTrue);
|
||||
expect(located.lat, closeTo(11.0183, 0.0001));
|
||||
expect(map().lat, located.lat);
|
||||
expect(pickupAddress(tester), located.title);
|
||||
|
||||
// 2 — dragging the map moves the same point.
|
||||
await tester.drag(find.byType(DmMapPanel), const Offset(-140, -110));
|
||||
await settle(tester, 900);
|
||||
final dragged = state.pickup!;
|
||||
expect(dragged.lat, isNot(located.lat));
|
||||
expect(dragged.hasLocation, isTrue);
|
||||
expect(map().lat, dragged.lat);
|
||||
expect(pickupAddress(tester), dragged.title);
|
||||
|
||||
// 3 — a search result overwrites it, coordinates included, and the map
|
||||
// follows the address rather than staying where the drag left it.
|
||||
await tester.tap(find.text('Change'));
|
||||
await settle(tester);
|
||||
await tester.tap(find.text('Brookefields Mall'));
|
||||
await settle(tester);
|
||||
|
||||
final searched = state.pickup!;
|
||||
expect(searched.title, 'Brookefields Mall');
|
||||
expect(searched.lat, closeTo(10.9987, 0.0001));
|
||||
expect(searched.lng, closeTo(76.9628, 0.0001));
|
||||
expect(map().lat, searched.lat);
|
||||
expect(map().lng, searched.lng);
|
||||
expect(pickupAddress(tester), 'Brookefields Mall');
|
||||
|
||||
// …and that one point is what the booking carries forward.
|
||||
await tester.tap(find.text('Next'));
|
||||
await settle(tester);
|
||||
expect(find.text('Where are you sending it?'), findsOneWidget);
|
||||
expect(state.draftPickup!.lat, searched.lat);
|
||||
expect(state.draftPickup!.lng, searched.lng);
|
||||
expect(state.draftPickup!.title, 'Brookefields Mall');
|
||||
await drainToasts(tester);
|
||||
});
|
||||
|
||||
testWidgets('the customer is never shown raw coordinates', (tester) async {
|
||||
final state = AppState(
|
||||
location: FixedLocationService(lat: 11.0183, lng: 76.9725),
|
||||
);
|
||||
await signIn(tester, state: state);
|
||||
await tester.tap(find.text('Start booking'));
|
||||
await settle(tester);
|
||||
|
||||
// Whatever the pin resolves to, it reads as a place — never a decimal pair.
|
||||
final coordinate = RegExp(r'-?\d{1,3}\.\d{3,}');
|
||||
for (final text in tester.widgetList<Text>(find.byType(Text))) {
|
||||
final data = text.data;
|
||||
if (data == null) continue;
|
||||
expect(
|
||||
coordinate.hasMatch(data),
|
||||
isFalse,
|
||||
reason: 'coordinates leaked into the UI: "$data"',
|
||||
);
|
||||
}
|
||||
expect(state.pickup!.hasLocation, isTrue);
|
||||
await drainToasts(tester);
|
||||
});
|
||||
|
||||
testWidgets('the customer sees their own position as a blue dot',
|
||||
(tester) async {
|
||||
final state = AppState(
|
||||
location: FixedLocationService(
|
||||
lat: 11.0183,
|
||||
lng: 76.9725,
|
||||
accuracy: 22,
|
||||
),
|
||||
);
|
||||
await signIn(tester, state: state);
|
||||
await tester.tap(find.text('Start booking'));
|
||||
await settle(tester);
|
||||
|
||||
final dot = find.byKey(const ValueKey('deviceLocationDot'));
|
||||
expect(dot, findsOneWidget);
|
||||
expect(state.hasDeviceLocation, isTrue);
|
||||
expect(state.deviceLat, closeTo(11.0183, 0.0001));
|
||||
expect(state.deviceAccuracy, 22);
|
||||
|
||||
// Dragging moves the pickup pin. It must not move the customer: the dot
|
||||
// stays where they actually are, which is the whole point of showing it.
|
||||
await tester.drag(find.byType(DmMapPanel), const Offset(-150, -120));
|
||||
await settle(tester, 900);
|
||||
|
||||
expect(dot, findsOneWidget);
|
||||
expect(state.deviceLat, closeTo(11.0183, 0.0001));
|
||||
expect(state.deviceLng, closeTo(76.9725, 0.0001));
|
||||
expect(state.pickup!.lat, isNot(closeTo(11.0183, 0.0001)));
|
||||
await drainToasts(tester);
|
||||
});
|
||||
|
||||
testWidgets('no blue dot when the device refused a position',
|
||||
(tester) async {
|
||||
LocationService.instance =
|
||||
FixedLocationService.denied(LocationDenial.serviceOff);
|
||||
|
||||
final state = AppState();
|
||||
await signIn(tester, state: state);
|
||||
await tester.tap(find.text('Start booking'));
|
||||
await settle(tester);
|
||||
|
||||
// A dot in the wrong place is worse than no dot.
|
||||
expect(find.byKey(const ValueKey('deviceLocationDot')), findsNothing);
|
||||
expect(state.hasDeviceLocation, isFalse);
|
||||
await drainToasts(tester);
|
||||
});
|
||||
|
||||
testWidgets('a refused location still lets the customer book',
|
||||
(tester) async {
|
||||
LocationService.instance =
|
||||
FixedLocationService.denied(LocationDenial.denied);
|
||||
|
||||
await signIn(tester);
|
||||
await tester.tap(find.text('Start booking'));
|
||||
await settle(tester);
|
||||
|
||||
// The map falls back to the service area rather than an empty ocean, and
|
||||
// the refusal is explained instead of silently swallowed.
|
||||
expect(
|
||||
find.textContaining('Allow location access'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('Search'), findsOneWidget);
|
||||
|
||||
// Booking is not blocked by it.
|
||||
expect(
|
||||
tester.widget<DmButton>(find.widgetWithText(DmButton, 'Next')).onPressed,
|
||||
isNotNull,
|
||||
);
|
||||
await drainToasts(tester);
|
||||
});
|
||||
|
||||
testWidgets('one destination, one package, every optional field skipped',
|
||||
(tester) async {
|
||||
final state = AppState();
|
||||
await signIn(tester, state: state);
|
||||
|
||||
await openDestination(tester);
|
||||
expect(find.text('Where are you sending it?'), findsOneWidget);
|
||||
|
||||
await pickPlace(tester, 'Tamil Nadu', 'Chennai');
|
||||
|
||||
// Settles into one card carrying the place and nothing else demanded.
|
||||
//
|
||||
// The package stepper and the optional delivery-details row were removed:
|
||||
// one package to one door is the whole of the common path, so the step now
|
||||
// asks for the destination and stops. `packageCount` still defaults to 1 in
|
||||
// the model, which the read-only screens go on showing.
|
||||
expect(find.text('Chennai, Tamil Nadu'), findsOneWidget);
|
||||
expect(find.text('How many packages?'), findsNothing);
|
||||
expect(find.text('Add exact delivery details'), findsNothing);
|
||||
expect(find.text('Send to another place'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Next'));
|
||||
await settle(tester);
|
||||
expect(find.text('When should we come?'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('2:00 – 4:00 PM').first);
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Review booking'));
|
||||
await settle(tester);
|
||||
|
||||
expect(find.text('Review pickup details'), findsOneWidget);
|
||||
expect(find.text('SENDING TO'), findsOneWidget);
|
||||
expect(find.text('1 package'), findsOneWidget);
|
||||
// Optional omissions are stated calmly, never as an error.
|
||||
expect(
|
||||
find.text('Not added — the Miler can confirm this at pickup.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
// A single destination shows no multi-destination summary.
|
||||
expect(find.textContaining('destinations'), findsNothing);
|
||||
|
||||
await tester.tap(find.text('Book pickup'));
|
||||
await settle(tester);
|
||||
|
||||
expect(find.text('Pickup booked'), findsOneWidget);
|
||||
final booking = state.trackedBooking!;
|
||||
expect(booking.destinations, hasLength(1));
|
||||
expect(booking.totalPackages, 1);
|
||||
expect(booking.destinations.first.trackingId, isNull);
|
||||
await drainToasts(tester);
|
||||
});
|
||||
|
||||
// ── These three used to drive the package stepper ──
|
||||
//
|
||||
// The stepper is gone from the destination step: one package to one door is
|
||||
// the whole common path, so the screen no longer asks. The *logic* it drove
|
||||
// is untouched — `setPackageCount` still clamps to `limits.maxPackages`, the
|
||||
// totals still roll up, and the booking payload still carries them — so the
|
||||
// counts are set on the state here and the assertions that matter (totals,
|
||||
// the cap, what review and the booking show) are kept exactly as they were.
|
||||
testWidgets('one destination, four packages', (tester) async {
|
||||
final state = AppState();
|
||||
await signIn(tester, state: state);
|
||||
|
||||
await openDestination(tester);
|
||||
await pickPlace(tester, 'Tamil Nadu', 'Coimbatore');
|
||||
|
||||
state.setPackageCount(0, 4);
|
||||
await tester.pump();
|
||||
expect(state.draftTotalPackages, 4);
|
||||
|
||||
await tester.tap(find.text('Next'));
|
||||
await settle(tester);
|
||||
await tester.tap(find.text('2:00 – 4:00 PM').first);
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Review booking'));
|
||||
await settle(tester);
|
||||
|
||||
expect(find.text('4 packages'), findsOneWidget);
|
||||
await drainToasts(tester);
|
||||
});
|
||||
|
||||
testWidgets('three destinations with mixed counts total correctly on review',
|
||||
(tester) async {
|
||||
final state = AppState();
|
||||
await signIn(tester, state: state);
|
||||
|
||||
await openDestination(tester);
|
||||
await pickPlace(tester, 'Tamil Nadu', 'Chennai');
|
||||
state.setPackageCount(0, 2);
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.text('Send to another place'));
|
||||
await settle(tester);
|
||||
await pickPlace(tester, 'Kerala', 'Ernakulam');
|
||||
|
||||
await tester.tap(find.text('Send to another place'));
|
||||
await settle(tester);
|
||||
await pickPlace(tester, 'Karnataka', 'Bengaluru Urban');
|
||||
state.setPackageCount(2, 3);
|
||||
await tester.pump();
|
||||
|
||||
expect(state.draftDestinations, hasLength(3));
|
||||
expect(state.draftTotalPackages, 6);
|
||||
|
||||
await tester.tap(find.text('Next'));
|
||||
await settle(tester);
|
||||
await tester.tap(find.text('2:00 – 4:00 PM').first);
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Review booking'));
|
||||
await settle(tester);
|
||||
|
||||
expect(find.text('6 packages · 3 destinations'), findsOneWidget);
|
||||
expect(find.text('Chennai, Tamil Nadu'), findsOneWidget);
|
||||
expect(find.text('Ernakulam, Kerala'), findsOneWidget);
|
||||
expect(find.text('Bengaluru Urban, Karnataka'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Book pickup'));
|
||||
await settle(tester);
|
||||
final booking = state.trackedBooking!;
|
||||
expect(booking.destinations, hasLength(3));
|
||||
expect(booking.totalPackages, 6);
|
||||
expect(booking.packageSummary, '6 packages · 3 destinations');
|
||||
// Before collection it is one pickup, however many places it serves.
|
||||
expect(
|
||||
state
|
||||
.entriesOf(BookingStatus.active)
|
||||
.where((e) => e.booking.reference == booking.reference)
|
||||
.length,
|
||||
1,
|
||||
);
|
||||
await drainToasts(tester);
|
||||
});
|
||||
|
||||
testWidgets('the package cap holds', (tester) async {
|
||||
final state = AppState();
|
||||
await signIn(tester, state: state);
|
||||
|
||||
await openDestination(tester);
|
||||
await pickPlace(tester, 'Tamil Nadu', 'Chennai');
|
||||
|
||||
// Push well past the cap; `setPackageCount` must clamp.
|
||||
state.setPackageCount(0, 999);
|
||||
await tester.pump();
|
||||
|
||||
expect(state.draftTotalPackages, state.limits.maxPackages);
|
||||
// A full pickup cannot take another place either.
|
||||
expect(state.canAddDestination, isFalse);
|
||||
expect(
|
||||
find.text('Up to ${state.limits.maxPackages} packages in one pickup.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
await drainToasts(tester);
|
||||
});
|
||||
|
||||
testWidgets('the destination cap holds', (tester) async {
|
||||
final state = AppState();
|
||||
await signIn(tester, state: state);
|
||||
|
||||
await openDestination(tester);
|
||||
|
||||
const places = [
|
||||
('Tamil Nadu', 'Chennai'),
|
||||
('Kerala', 'Ernakulam'),
|
||||
('Karnataka', 'Mysuru'),
|
||||
('Tamil Nadu', 'Erode'),
|
||||
('Kerala', 'Thrissur'),
|
||||
];
|
||||
|
||||
for (var i = 0; i < places.length; i++) {
|
||||
if (i > 0) {
|
||||
await tester.tap(find.text('Send to another place'));
|
||||
await settle(tester);
|
||||
}
|
||||
await pickPlace(tester, places[i].$1, places[i].$2);
|
||||
}
|
||||
|
||||
expect(state.draftDestinations, hasLength(state.limits.maxDestinations));
|
||||
expect(state.canAddDestination, isFalse);
|
||||
expect(find.text('Send to another place'), findsNothing);
|
||||
expect(
|
||||
find.text(
|
||||
'Up to ${state.limits.maxDestinations} places in one pickup. '
|
||||
'Book another pickup for more.',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
await drainToasts(tester);
|
||||
});
|
||||
|
||||
testWidgets('the picker offers serviceable districts only', (tester) async {
|
||||
await signIn(tester);
|
||||
await openDestination(tester);
|
||||
|
||||
// Nothing until a state is picked.
|
||||
expect(find.text('Chennai'), findsNothing);
|
||||
|
||||
await tapState(tester, 'Tamil Nadu');
|
||||
expect(find.text('Chennai'), findsOneWidget);
|
||||
expect(find.text('Coimbatore'), findsOneWidget);
|
||||
expect(find.text('Ernakulam'), findsNothing);
|
||||
|
||||
// Unsupported places are never disabled tiles — they are named once,
|
||||
// quietly, and only because they are genuinely coming.
|
||||
expect(find.text('Madurai'), findsNothing);
|
||||
expect(find.text('Coming soon: Madurai'), findsOneWidget);
|
||||
|
||||
await tapState(tester, 'Kerala');
|
||||
expect(find.text('Ernakulam'), findsOneWidget);
|
||||
expect(find.text('Palakkad'), findsOneWidget);
|
||||
expect(find.text('Chennai'), findsNothing);
|
||||
expect(find.text('Coming soon: Kozhikode'), findsOneWidget);
|
||||
await drainToasts(tester);
|
||||
});
|
||||
|
||||
testWidgets('a state with nothing open is not offered at all', (tester) async {
|
||||
await signIn(tester);
|
||||
await openDestination(tester);
|
||||
|
||||
// Puducherry is in the backend response but has no open district, so the
|
||||
// customer never has to read past it.
|
||||
expect(find.text('Puducherry'), findsNothing);
|
||||
expect(find.text('Tamil Nadu'), findsOneWidget);
|
||||
await drainToasts(tester);
|
||||
});
|
||||
|
||||
testWidgets('orders split into their own rows once collected', (tester) async {
|
||||
final state = AppState();
|
||||
await signIn(tester, state: state);
|
||||
|
||||
await tester.tap(find.text('Orders'));
|
||||
await settle(tester);
|
||||
|
||||
// Before collection: one row, owned by the pickup.
|
||||
expect(find.text('DM-482913'), findsOneWidget);
|
||||
|
||||
// After collection: the seeded multi-destination pickup is in transit, so
|
||||
// each destination stands on its own with a quiet line back to the visit.
|
||||
final booking = state.byReference('DM-471200')!;
|
||||
expect(booking.hasOrders, isTrue);
|
||||
expect(find.text('Pickup DM-471200'), findsNothing);
|
||||
expect(find.text('Ernakulam, Kerala'), findsOneWidget);
|
||||
expect(find.text('Coimbatore, Tamil Nadu'), findsOneWidget);
|
||||
expect(
|
||||
find.text('Collected on DM-471200'),
|
||||
findsNWidgets(booking.destinations.length),
|
||||
);
|
||||
// Independently tracked.
|
||||
expect(
|
||||
booking.destinations.map((d) => d.trackingId).toSet().length,
|
||||
booking.destinations.length,
|
||||
);
|
||||
|
||||
// Opening one follows that order, not the whole visit.
|
||||
await tester.tap(find.text('Ernakulam, Kerala'));
|
||||
await settle(tester);
|
||||
expect(find.text('To Ernakulam, Kerala'), findsOneWidget);
|
||||
expect(find.text('YOUR PACKAGES'), findsOneWidget);
|
||||
await drainToasts(tester);
|
||||
});
|
||||
|
||||
testWidgets('the customer timeline groups the operational states',
|
||||
(tester) async {
|
||||
final state = AppState();
|
||||
await signIn(tester, state: state);
|
||||
|
||||
await tester.tap(find.text('Miler assigned'));
|
||||
await settle(tester);
|
||||
|
||||
// Tracking opens on the three most recent updates; the rest of the journey
|
||||
// is one tap away.
|
||||
await tester.tap(find.text('All 7 stages'));
|
||||
await settle(tester);
|
||||
|
||||
// Seven customer milestones, not nine backend states.
|
||||
expect(find.text('Pickup booked'), findsWidgets);
|
||||
expect(find.text('Pickup in progress'), findsOneWidget);
|
||||
expect(find.text('Package collected'), findsOneWidget);
|
||||
// Operational states are never timeline rows of their own.
|
||||
expect(find.text('Miler arrived'), findsNothing);
|
||||
expect(find.text('Order created'), findsNothing);
|
||||
|
||||
// Advancing to "on the way" keeps the milestone and moves the detail. It
|
||||
// reads twice: once on the timeline, and once as the caption on the live
|
||||
// card's own progress rail.
|
||||
await serverAdvance(tester, state, 'DM-482913', JourneyStage.onTheWay);
|
||||
expect(find.text('Pickup in progress'), findsNWidgets(2));
|
||||
expect(find.textContaining('Miler on the way'), findsWidgets);
|
||||
// The pill speaks customer language, not the operational stage.
|
||||
expect(find.text('Order created'), findsNothing);
|
||||
await drainToasts(tester);
|
||||
});
|
||||
|
||||
testWidgets('packages stay together until the pickup is actually collected',
|
||||
(tester) async {
|
||||
final state = AppState();
|
||||
await signIn(tester, state: state);
|
||||
|
||||
await tester.tap(find.text('Miler assigned'));
|
||||
await settle(tester);
|
||||
|
||||
// The live card, not an eyebrow: a pickup in flight is the screen's one
|
||||
// crimson surface.
|
||||
expect(find.text('LIVE'), findsOneWidget);
|
||||
expect(find.text('Cancel this pickup'), findsOneWidget);
|
||||
// No per-package breakdown before collection.
|
||||
expect(find.text('YOUR PACKAGES'), findsNothing);
|
||||
|
||||
await serverAdvance(tester, state, 'DM-482913', JourneyStage.pickedUp);
|
||||
// Custody has changed hands: cancellation is no longer offered.
|
||||
expect(find.text('Cancel this pickup'), findsNothing);
|
||||
|
||||
await serverAdvance(tester, state, 'DM-482913', JourneyStage.orderCreated);
|
||||
final booking = state.byReference('DM-482913')!;
|
||||
expect(booking.stage, JourneyStage.orderCreated);
|
||||
expect(booking.destinations.first.trackingId, isNotNull);
|
||||
await drainToasts(tester);
|
||||
});
|
||||
|
||||
testWidgets('cancels the whole pickup from tracking', (tester) async {
|
||||
final state = AppState();
|
||||
await signIn(tester, state: state);
|
||||
|
||||
await tester.tap(find.text('Miler assigned'));
|
||||
await settle(tester);
|
||||
|
||||
await tester.tap(find.text('Cancel this pickup'));
|
||||
await settle(tester);
|
||||
expect(find.text('Cancel this pickup?'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Package not ready'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Yes, cancel pickup'));
|
||||
await settle(tester);
|
||||
|
||||
expect(find.text('Booking cancelled'), findsOneWidget);
|
||||
final booking = state.byReference('DM-482913')!;
|
||||
expect(booking.status, BookingStatus.cancelled);
|
||||
await drainToasts(tester);
|
||||
});
|
||||
|
||||
testWidgets('shows the empty service-area state', (tester) async {
|
||||
await signIn(tester);
|
||||
api.flags.emptyServiceAreas = true;
|
||||
|
||||
await openDestination(tester);
|
||||
|
||||
expect(find.text('No service areas available'), findsOneWidget);
|
||||
await drainToasts(tester);
|
||||
});
|
||||
|
||||
testWidgets('shows the network error state with retry', (tester) async {
|
||||
await signIn(tester);
|
||||
api.flags.networkError = true;
|
||||
|
||||
await openDestination(tester);
|
||||
|
||||
expect(find.text("Couldn't load this"), findsOneWidget);
|
||||
|
||||
api.flags.networkError = false;
|
||||
await tester.tap(find.text('Retry'));
|
||||
await settle(tester);
|
||||
expect(find.text('Tamil Nadu'), findsOneWidget);
|
||||
await drainToasts(tester);
|
||||
});
|
||||
|
||||
testWidgets('a delivered order opens its receipt', (tester) async {
|
||||
await signIn(tester);
|
||||
|
||||
await tester.tap(find.text('Orders'));
|
||||
await settle(tester);
|
||||
await tester.tap(find.text('Completed'));
|
||||
await settle(tester);
|
||||
|
||||
await tester.tap(find.text('Salem, Tamil Nadu'));
|
||||
await settle(tester);
|
||||
|
||||
expect(find.text('Delivered'), findsWidgets);
|
||||
expect(find.text('Total paid'), findsOneWidget);
|
||||
expect(find.text('₹64'), findsWidgets);
|
||||
|
||||
// What the Miler recorded at the door.
|
||||
expect(find.text('COLLECTED AT YOUR DOOR'), findsOneWidget);
|
||||
expect(find.text('Miler verified'), findsOneWidget);
|
||||
// Once on the parcel block, once in the payment breakdown.
|
||||
expect(find.text('1.6 kg'), findsNWidgets(2));
|
||||
expect(find.text('Verified weight'), findsOneWidget);
|
||||
expect(find.text('1 of 1'), findsOneWidget);
|
||||
expect(find.textContaining('Weighed and photographed by'), findsOneWidget);
|
||||
await drainToasts(tester);
|
||||
});
|
||||
|
||||
testWidgets('key screens survive large accessibility text', (tester) async {
|
||||
// Any overflow throws, so simply building these screens is the assertion.
|
||||
tester.platformDispatcher.textScaleFactorTestValue = 1.8;
|
||||
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
|
||||
|
||||
await signIn(tester); // login + OTP
|
||||
expect(find.text('Start booking'), findsOneWidget); // home
|
||||
|
||||
await openDestination(tester); // pickup point + destination
|
||||
await pickPlace(tester, 'Tamil Nadu', 'Chennai');
|
||||
expect(find.text('Chennai, Tamil Nadu'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Next'));
|
||||
await settle(tester); // pickup time
|
||||
await tester.tap(find.text('2:00 – 4:00 PM').first);
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Review booking'));
|
||||
await settle(tester); // review
|
||||
expect(find.text('Review pickup details'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Book pickup'));
|
||||
await settle(tester); // confirmation
|
||||
await tester.tap(find.text('Track pickup'));
|
||||
await settle(tester); // tracking
|
||||
expect(find.text('Pickup booked'), findsWidgets);
|
||||
|
||||
await drainToasts(tester);
|
||||
});
|
||||
|
||||
testWidgets('sign up survives large accessibility text', (tester) async {
|
||||
tester.platformDispatcher.textScaleFactorTestValue = 1.8;
|
||||
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
|
||||
|
||||
tester.view.physicalSize = const Size(1230, 9000);
|
||||
tester.view.devicePixelRatio = 3.0;
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
await tester.pumpWidget(const DoormileApp());
|
||||
await settle(tester);
|
||||
await tester.tap(find.text('Create account'));
|
||||
await settle(tester);
|
||||
|
||||
// Building it is the assertion: any overflow in the hero or the form
|
||||
// throws.
|
||||
expect(find.text('Create your account.'), findsOneWidget);
|
||||
await drainToasts(tester);
|
||||
});
|
||||
|
||||
testWidgets('orders and account survive large accessibility text',
|
||||
(tester) async {
|
||||
tester.platformDispatcher.textScaleFactorTestValue = 1.8;
|
||||
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
|
||||
|
||||
await signIn(tester);
|
||||
await tester.tap(find.text('Orders'));
|
||||
await settle(tester);
|
||||
await tester.tap(find.text('Completed'));
|
||||
await settle(tester);
|
||||
await tester.tap(find.text('Salem, Tamil Nadu'));
|
||||
await settle(tester); // receipt
|
||||
|
||||
await drainToasts(tester);
|
||||
});
|
||||
|
||||
testWidgets('orders tabs filter by status', (tester) async {
|
||||
final state = AppState();
|
||||
await signIn(tester, state: state);
|
||||
|
||||
await tester.tap(find.text('Orders'));
|
||||
await settle(tester);
|
||||
// Each tab carries its own count, inside the header's segmented control —
|
||||
// and the figure is the rows that tab will actually show, which for a
|
||||
// collected multi-destination pickup is one per destination.
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(DmSegmented),
|
||||
matching: find.text('${state.entriesOf(BookingStatus.active).length}'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Cancelled'));
|
||||
await settle(tester);
|
||||
expect(find.text('Erode, Tamil Nadu'), findsOneWidget);
|
||||
await drainToasts(tester);
|
||||
});
|
||||
}
|
||||
170
test/design_snapshot_test.dart
Normal file
@@ -0,0 +1,170 @@
|
||||
@Tags(['snapshot'])
|
||||
library;
|
||||
|
||||
import 'package:doormile_cx/data/dev_doormile_api.dart';
|
||||
import 'package:doormile_cx/data/doormile_api.dart';
|
||||
import 'package:doormile_cx/data/location_service.dart';
|
||||
import 'package:doormile_cx/main.dart';
|
||||
import 'package:doormile_cx/state/app_state.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
/// Renders each screen at phone size and writes it to `test/snapshots/`.
|
||||
///
|
||||
/// This is a **design** check, not a regression check. A deliberate redesign
|
||||
/// would fail every one of these, which is worthless as a test signal, so the
|
||||
/// `snapshot` tag is skipped in a normal run (see `dart_test.yaml`) and the
|
||||
/// pictures are rendered on demand:
|
||||
///
|
||||
/// flutter test test/design_snapshot_test.dart --run-skipped --update-goldens
|
||||
///
|
||||
/// It exists because the only honest way to review a visual rebuild is to look
|
||||
/// at it, and this produces the same pictures on any machine, with the real
|
||||
/// fonts, without a device attached.
|
||||
///
|
||||
/// The real fonts are loaded first — without that, Flutter's test harness draws
|
||||
/// everything in Ahem's black boxes and the pictures are worthless.
|
||||
Future<void> _loadFonts() async {
|
||||
for (final (family, asset) in const [
|
||||
('Manrope', 'assets/fonts/Manrope-VF.ttf'),
|
||||
('GeistMono', 'assets/fonts/GeistMono-VF.ttf'),
|
||||
// The icon font too, or every glyph in the picture is a hollow box.
|
||||
('packages/lucide_icons_flutter/Lucide',
|
||||
'packages/lucide_icons_flutter/assets/lucide.ttf'),
|
||||
]) {
|
||||
final loader = FontLoader(family)..addFont(rootBundle.load(asset));
|
||||
await loader.load();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _settle(WidgetTester tester, [int ms = 800]) async {
|
||||
await tester.pump();
|
||||
await tester.pump(Duration(milliseconds: ms));
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
}
|
||||
|
||||
Future<void> _shot(WidgetTester tester, String name) async {
|
||||
await expectLater(
|
||||
find.byType(MaterialApp),
|
||||
matchesGoldenFile('snapshots/$name.png'),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
late DevDoormileApi api;
|
||||
|
||||
setUp(() {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(
|
||||
const MethodChannel('plugins.flutter.io/path_provider'),
|
||||
(call) async => '/tmp',
|
||||
);
|
||||
api = DevDoormileApi();
|
||||
DoormileApi.overrideInstance(api);
|
||||
LocationService.instance = FixedLocationService();
|
||||
});
|
||||
|
||||
tearDown(() => DoormileApi.overrideInstance(null));
|
||||
|
||||
testWidgets('screens', (tester) async {
|
||||
await _loadFonts();
|
||||
|
||||
// One phone, portrait: 390x844 at 3x, the frame the design was drawn in.
|
||||
tester.view.physicalSize = const Size(1170, 2532);
|
||||
tester.view.devicePixelRatio = 3.0;
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
final state = AppState();
|
||||
await tester.pumpWidget(DoormileApp(initialState: state));
|
||||
await _settle(tester);
|
||||
|
||||
// Image.asset resolves off the frame loop, so in a test it never paints
|
||||
// unless it is decoded first — without this the wordmark is a blank gap in
|
||||
// every picture of the entrance.
|
||||
await tester.runAsync(() async {
|
||||
await precacheImage(
|
||||
const AssetImage('assets/images/doormile-logo.png'),
|
||||
tester.element(find.byType(MaterialApp)),
|
||||
);
|
||||
});
|
||||
await _settle(tester);
|
||||
await _shot(tester, '01-sign-in');
|
||||
|
||||
await tester.tap(find.text('Create account'));
|
||||
await _settle(tester);
|
||||
await _shot(tester, '01b-sign-up');
|
||||
await tester.tap(find.byIcon(LucideIcons.arrowLeft).first);
|
||||
await _settle(tester);
|
||||
|
||||
await tester.enterText(find.byType(TextField).first, '9876543210');
|
||||
await _settle(tester, 200);
|
||||
await tester.tap(find.text('Continue'));
|
||||
await _settle(tester);
|
||||
await _shot(tester, '02-verify');
|
||||
|
||||
final boxes = find.byType(TextField);
|
||||
for (var i = 0; i < 4; i++) {
|
||||
await tester.enterText(boxes.at(i), '1');
|
||||
await tester.pump();
|
||||
}
|
||||
await _settle(tester);
|
||||
await _shot(tester, '03-home');
|
||||
|
||||
await tester.tap(find.text('Orders'));
|
||||
await _settle(tester);
|
||||
await _shot(tester, '04-orders');
|
||||
|
||||
await tester.tap(find.text('Account'));
|
||||
await _settle(tester);
|
||||
await _shot(tester, '05-account');
|
||||
|
||||
await tester.tap(find.text('Home'));
|
||||
await _settle(tester);
|
||||
await tester.tap(find.text('Miler assigned'));
|
||||
await _settle(tester);
|
||||
await _shot(tester, '06-tracking');
|
||||
await tester.tap(find.byIcon(LucideIcons.arrowLeft).first);
|
||||
await _settle(tester);
|
||||
|
||||
// Booking flow.
|
||||
await tester.tap(find.text('Start booking'));
|
||||
await _settle(tester);
|
||||
await _shot(tester, '07-pickup-point');
|
||||
await tester.tap(find.text('Next'));
|
||||
await _settle(tester);
|
||||
await tester.tap(find.text('Tamil Nadu'));
|
||||
await _settle(tester);
|
||||
await tester.tap(find.text('Chennai'));
|
||||
await _settle(tester);
|
||||
await _shot(tester, '08-destination');
|
||||
|
||||
await tester.tap(find.text('Next'));
|
||||
await _settle(tester);
|
||||
await tester.tap(find.text('2:00 – 4:00 PM').first);
|
||||
await _settle(tester);
|
||||
await _shot(tester, '09-slot');
|
||||
|
||||
await tester.tap(find.text('Review booking'));
|
||||
await _settle(tester);
|
||||
await _shot(tester, '10-review');
|
||||
|
||||
await tester.tap(find.text('Book pickup'));
|
||||
await _settle(tester);
|
||||
await _shot(tester, '11-booked');
|
||||
|
||||
// A finished order, for the receipt treatment.
|
||||
await tester.tap(find.text('Back to home'));
|
||||
await _settle(tester);
|
||||
await tester.tap(find.text('Orders'));
|
||||
await _settle(tester);
|
||||
await tester.tap(find.text('Completed'));
|
||||
await _settle(tester);
|
||||
await tester.tap(find.text('Salem, Tamil Nadu'));
|
||||
await _settle(tester);
|
||||
await _shot(tester, '12-receipt');
|
||||
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
});
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:doormile/main.dart';
|
||||
|
||||
/// Drives the app through its main flows at a phone-sized viewport to catch
|
||||
/// layout overflows and runtime exceptions on each screen.
|
||||
void main() {
|
||||
testWidgets('full user journey renders without errors', (WidgetTester tester) async {
|
||||
tester.view.physicalSize = const Size(1170, 2532); // iPhone-ish @3x
|
||||
tester.view.devicePixelRatio = 3.0;
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
await tester.pumpWidget(const DoormileApp());
|
||||
|
||||
// Splash -> Onboarding
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Send a parcel in one touch'), findsOneWidget);
|
||||
|
||||
// Skip onboarding -> Login
|
||||
await tester.tap(find.text('Skip'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Welcome back'), findsOneWidget);
|
||||
|
||||
// Enter phone and request OTP
|
||||
await tester.enterText(find.byType(TextField).first, '9876543210');
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Send OTP'));
|
||||
await tester.pump(const Duration(milliseconds: 1400));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Verify your number'), findsOneWidget);
|
||||
|
||||
// Fill the 6 OTP boxes
|
||||
final otpBoxes = find.byType(TextField);
|
||||
for (int i = 0; i < 6; i++) {
|
||||
await tester.enterText(otpBoxes.at(i), '1');
|
||||
await tester.pump();
|
||||
}
|
||||
await tester.tap(find.text('Verify & Continue'));
|
||||
await tester.pump(const Duration(milliseconds: 1300));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Home
|
||||
expect(find.text('Send a parcel'), findsOneWidget);
|
||||
|
||||
// Start a booking
|
||||
await tester.tap(find.text('Book Now'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Step 1: Confirm Pickup'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Continue'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('What are you sending?'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Continue'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('CHOOSE SERVICE'), findsOneWidget);
|
||||
|
||||
// Confirm & Continue -> review
|
||||
await tester.tap(find.textContaining('Confirm & Continue'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Review Booking'), findsWidgets);
|
||||
|
||||
// Pop all the way back to the home shell.
|
||||
for (int i = 0; i < 4; i++) {
|
||||
if (find.text('Send a parcel').evaluate().isNotEmpty) break;
|
||||
await tester.pageBack();
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
expect(find.text('Send a parcel'), findsOneWidget);
|
||||
|
||||
// Wallet tab
|
||||
await tester.tap(find.text('Wallet').first);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Doormile Wallet'), findsOneWidget);
|
||||
|
||||
// Profile tab
|
||||
await tester.tap(find.text('Profile').first);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Riya Sharma'), findsOneWidget);
|
||||
|
||||
// Track tab (orders list)
|
||||
await tester.tap(find.text('Track').first);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('My Parcels'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
297
test/live_api_wire_test.dart
Normal file
@@ -0,0 +1,297 @@
|
||||
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);
|
||||
});
|
||||
}
|
||||
89
test/login_otp_guard_test.dart
Normal file
@@ -0,0 +1,89 @@
|
||||
import 'package:doormile_cx/data/dev_doormile_api.dart';
|
||||
import 'package:doormile_cx/data/models.dart';
|
||||
import 'package:doormile_cx/main.dart';
|
||||
import 'package:doormile_cx/state/app_state.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
/// The login screen must not walk a customer to the code screen when no code
|
||||
/// was sent.
|
||||
///
|
||||
/// `sent: false` is the server saying it accepted the request and delivered
|
||||
/// nothing — the SMS gateway or the mail relay refused it. Advancing anyway
|
||||
/// puts four empty boxes in front of someone for a code that does not exist,
|
||||
/// and every digit they try is wrong. The failure then reads as "I typed it
|
||||
/// wrong" instead of "nothing arrived", which is the wrong problem to hand
|
||||
/// somebody.
|
||||
///
|
||||
/// The resend control on the code screen has always honoured this flag. The
|
||||
/// login screen did not, which is the asymmetry these tests pin down.
|
||||
|
||||
/// The app runs continuous animations, so `pumpAndSettle` never returns.
|
||||
Future<void> settle(WidgetTester tester, [int ms = 800]) async {
|
||||
await tester.pump();
|
||||
await tester.pump(Duration(milliseconds: ms));
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
}
|
||||
|
||||
class _SendFails extends DevDoormileApi {
|
||||
@override
|
||||
Future<OtpChallenge> sendOtp(String identifier) async =>
|
||||
const OtpChallenge(sent: false);
|
||||
}
|
||||
|
||||
class _SendSucceeds extends DevDoormileApi {
|
||||
var calls = 0;
|
||||
|
||||
@override
|
||||
Future<OtpChallenge> sendOtp(String identifier) async {
|
||||
calls++;
|
||||
return const OtpChallenge(sent: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _tapContinue(WidgetTester tester, AppState state) async {
|
||||
tester.view.physicalSize = const Size(1230, 9000);
|
||||
tester.view.devicePixelRatio = 3.0;
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
await tester.pumpWidget(DoormileApp(initialState: state));
|
||||
await settle(tester);
|
||||
|
||||
await tester.enterText(find.byType(TextField).first, '9876543210');
|
||||
await settle(tester, 200);
|
||||
await tester.tap(find.text('Continue'));
|
||||
await settle(tester);
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('a code that was never sent does not open the code screen',
|
||||
(tester) async {
|
||||
await _tapContinue(tester, AppState(api: _SendFails()));
|
||||
|
||||
expect(
|
||||
find.text('Verify your number'),
|
||||
findsNothing,
|
||||
reason: 'no code was delivered, so there is nothing to verify',
|
||||
);
|
||||
expect(
|
||||
// The entry screen's own headline, which now sits on the brand field
|
||||
// rather than as a heading inside the sheet.
|
||||
find.text('Book a pickup from your door.'),
|
||||
findsOneWidget,
|
||||
reason: 'the customer stays where they can try again',
|
||||
);
|
||||
|
||||
// Drain the toast timer so the test ends with nothing pending.
|
||||
await tester.pump(const Duration(seconds: 4));
|
||||
});
|
||||
|
||||
testWidgets('a delivered code does open the code screen', (tester) async {
|
||||
final api = _SendSucceeds();
|
||||
await _tapContinue(tester, AppState(api: api));
|
||||
|
||||
expect(find.text('Verify your number'), findsOneWidget);
|
||||
expect(api.calls, 1);
|
||||
|
||||
await tester.pump(const Duration(seconds: 4));
|
||||
});
|
||||
}
|
||||
96
test/map_config_test.dart
Normal file
@@ -0,0 +1,96 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:doormile_cx/data/map_config.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('tile provider', () {
|
||||
test('CARTO serves anonymously and takes a key when one is supplied', () {
|
||||
const anonymous = MapTileProvider.carto;
|
||||
expect(anonymous.usable, isTrue);
|
||||
expect(anonymous.hasKey, isFalse);
|
||||
expect(anonymous.urlFor(DmMapStyle.streets), isNot(contains('api_key')));
|
||||
|
||||
final keyed = anonymous.withKey('abc123');
|
||||
expect(keyed.hasKey, isTrue);
|
||||
expect(
|
||||
keyed.urlFor(DmMapStyle.streets),
|
||||
endsWith('?api_key=abc123'),
|
||||
);
|
||||
// Nothing else about the source changes when the key arrives.
|
||||
expect(keyed.attribution, anonymous.attribution);
|
||||
expect(keyed.subdomains, anonymous.subdomains);
|
||||
});
|
||||
|
||||
test('the two styles are distinct sources', () {
|
||||
expect(
|
||||
MapTileProvider.carto.urlFor(DmMapStyle.streets),
|
||||
isNot(MapTileProvider.carto.urlFor(DmMapStyle.muted)),
|
||||
);
|
||||
});
|
||||
|
||||
test('a key-only provider reports itself unusable without one', () {
|
||||
expect(MapTileProvider.maptiler.usable, isFalse);
|
||||
expect(MapTileProvider.maptiler.withKey('k').usable, isTrue);
|
||||
expect(
|
||||
MapTileProvider.maptiler.withKey('k').urlFor(DmMapStyle.streets),
|
||||
endsWith('?key=k'),
|
||||
);
|
||||
expect(MapTileProvider.stadia.usable, isFalse);
|
||||
});
|
||||
|
||||
test('every preset credits its data source', () {
|
||||
for (final provider in MapTileProvider.presets.values) {
|
||||
expect(
|
||||
provider.attribution,
|
||||
contains('OpenStreetMap'),
|
||||
reason: '${provider.id} must credit OpenStreetMap',
|
||||
);
|
||||
expect(provider.attributionUrl, startsWith('https://'));
|
||||
expect(provider.templates[DmMapStyle.streets], isNotNull);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('resolved configuration', () {
|
||||
test('defaults to CARTO and identifies the app to the provider', () {
|
||||
final config = DmMapConfig.fromEnvironment();
|
||||
expect(config.provider.id, 'carto');
|
||||
expect(config.warning, isNull);
|
||||
expect(config.userAgent, contains('Doormile/'));
|
||||
expect(config.userAgent, contains(DmMapConfig.packageName));
|
||||
expect(config.attribution, contains('CARTO'));
|
||||
});
|
||||
|
||||
test('is swappable without touching a screen', () {
|
||||
final original = DmMapConfig.instance;
|
||||
addTearDown(() => DmMapConfig.instance = original);
|
||||
|
||||
DmMapConfig.instance = DmMapConfig(
|
||||
provider: MapTileProvider.osm,
|
||||
userAgent: 'test/1.0',
|
||||
);
|
||||
expect(DmMapConfig.instance.attribution, '© OpenStreetMap contributors');
|
||||
expect(DmMapConfig.instance.retina, isFalse);
|
||||
expect(DmMapConfig.instance.maxNativeZoom, 19);
|
||||
});
|
||||
});
|
||||
|
||||
test('no tile URL, subdomain list or map key lives in the UI layer', () {
|
||||
// The whole point of the provider layer: swapping CARTO for a keyed plan or
|
||||
// another vendor must never mean editing a map screen.
|
||||
final offenders = <String>[];
|
||||
for (final entry in Directory('lib/ui').listSync(recursive: true)) {
|
||||
if (entry is! File || !entry.path.endsWith('.dart')) continue;
|
||||
final source = entry.readAsStringSync();
|
||||
if (source.contains('cartocdn') ||
|
||||
source.contains('tile.openstreetmap.org') ||
|
||||
source.contains('api.maptiler.com') ||
|
||||
source.contains('tiles.stadiamaps.com') ||
|
||||
source.contains('{z}/{x}/{y}')) {
|
||||
offenders.add(entry.path);
|
||||
}
|
||||
}
|
||||
expect(offenders, isEmpty, reason: 'tile URLs belong in map_config.dart');
|
||||
});
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:doormile/state/app_state.dart';
|
||||
import 'package:doormile/theme/app_theme.dart';
|
||||
import 'package:doormile/screens/onboarding_screen.dart';
|
||||
import 'package:doormile/screens/login_screen.dart';
|
||||
import 'package:doormile/screens/otp_screen.dart';
|
||||
import 'package:doormile/screens/main_shell.dart';
|
||||
import 'package:doormile/screens/home_screen.dart';
|
||||
import 'package:doormile/screens/wallet_screen.dart';
|
||||
import 'package:doormile/screens/profile_screen.dart';
|
||||
import 'package:doormile/screens/help_support_screen.dart';
|
||||
import 'package:doormile/screens/booking/booking_pickup_screen.dart';
|
||||
import 'package:doormile/screens/booking/parcel_details_screen.dart';
|
||||
import 'package:doormile/screens/booking/service_price_screen.dart';
|
||||
import 'package:doormile/screens/booking/booking_preview_screen.dart';
|
||||
import 'package:doormile/screens/booking/booking_success_screen.dart';
|
||||
import 'package:doormile/screens/tracking/my_orders_screen.dart';
|
||||
import 'package:doormile/screens/tracking/live_tracking_screen.dart';
|
||||
import 'package:doormile/screens/tracking/shipment_journey_screen.dart';
|
||||
|
||||
/// Renders each screen in isolation at a phone viewport and settles it, so the
|
||||
/// test framework flags any overflow in the *settled* layout (no transition
|
||||
/// transients). A pumped order is placed first so success/tracking have data.
|
||||
Future<void> _renderScreen(WidgetTester tester, Widget child) async {
|
||||
final state = AppState()
|
||||
..login()
|
||||
..setPhone('98765 43210');
|
||||
state.placeOrder();
|
||||
await tester.pumpWidget(
|
||||
ChangeNotifierProvider<AppState>.value(
|
||||
value: state,
|
||||
child: MaterialApp(theme: AppTheme.light, home: child),
|
||||
),
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUp(() {});
|
||||
|
||||
final screens = <String, Widget Function()>{
|
||||
'Onboarding': () => const OnboardingScreen(),
|
||||
'Login': () => const LoginScreen(),
|
||||
'OTP': () => const OtpScreen(),
|
||||
'MainShell/Home': () => const MainShell(),
|
||||
'Home': () => const Scaffold(body: HomeScreen()),
|
||||
'Wallet': () => const Scaffold(body: WalletScreen()),
|
||||
'Profile': () => const Scaffold(body: ProfileScreen()),
|
||||
'Help': () => const HelpSupportScreen(),
|
||||
'BookingPickup': () => const BookingPickupScreen(),
|
||||
'ParcelDetails': () => const ParcelDetailsScreen(),
|
||||
'ServicePrice': () => const ServicePriceScreen(),
|
||||
'BookingPreview': () => const BookingPreviewScreen(),
|
||||
'BookingSuccess': () => const BookingSuccessScreen(),
|
||||
'MyOrders': () => const MyOrdersScreen(),
|
||||
'LiveTracking': () => const LiveTrackingScreen(),
|
||||
'ShipmentJourney': () => const ShipmentJourneyScreen(),
|
||||
};
|
||||
|
||||
for (final entry in screens.entries) {
|
||||
testWidgets('${entry.key} renders cleanly at phone size', (tester) async {
|
||||
tester.view.physicalSize = const Size(1170, 2532);
|
||||
tester.view.devicePixelRatio = 3.0;
|
||||
addTearDown(tester.view.reset);
|
||||
await _renderScreen(tester, entry.value());
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
}
|
||||
BIN
test/snapshots/01-sign-in.png
Normal file
|
After Width: | Height: | Size: 199 KiB |
BIN
test/snapshots/01b-sign-up.png
Normal file
|
After Width: | Height: | Size: 227 KiB |
BIN
test/snapshots/02-verify.png
Normal file
|
After Width: | Height: | Size: 140 KiB |
BIN
test/snapshots/03-home.png
Normal file
|
After Width: | Height: | Size: 204 KiB |
BIN
test/snapshots/04-orders.png
Normal file
|
After Width: | Height: | Size: 240 KiB |
BIN
test/snapshots/05-account.png
Normal file
|
After Width: | Height: | Size: 195 KiB |
BIN
test/snapshots/06-tracking.png
Normal file
|
After Width: | Height: | Size: 203 KiB |
BIN
test/snapshots/07-pickup-point.png
Normal file
|
After Width: | Height: | Size: 136 KiB |
BIN
test/snapshots/08-destination.png
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
test/snapshots/09-slot.png
Normal file
|
After Width: | Height: | Size: 204 KiB |
BIN
test/snapshots/10-review.png
Normal file
|
After Width: | Height: | Size: 228 KiB |
BIN
test/snapshots/11-booked.png
Normal file
|
After Width: | Height: | Size: 221 KiB |
BIN
test/snapshots/12-receipt.png
Normal file
|
After Width: | Height: | Size: 178 KiB |
@@ -1,19 +0,0 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:doormile/main.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('App boots and advances from splash to onboarding',
|
||||
(WidgetTester tester) async {
|
||||
await tester.pumpWidget(const DoormileApp());
|
||||
|
||||
// Splash shows the brand wordmark.
|
||||
expect(find.text('Doormile'), findsWidgets);
|
||||
|
||||
// Let the splash auto-navigation timer fire, then settle on onboarding.
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Send a parcel in one touch'), findsOneWidget);
|
||||
});
|
||||
}
|
||||