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>
433 lines
13 KiB
Dart
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);
|
|
});
|
|
});
|
|
}
|