Files
doormile_customer_app/test/api_client_test.dart
Thiru-tenext 0d66627c3c 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>
2026-09-15 16:07:33 +05:30

433 lines
13 KiB
Dart

import 'dart:convert';
import 'package:doormile_cx/data/api_client.dart';
import 'package:doormile_cx/data/api_exception.dart';
import 'package:doormile_cx/data/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);
});
});
}