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>
This commit is contained in:
432
test/api_client_test.dart
Normal file
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user