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 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? 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().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() .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('502 Bad Gateway', 502), ), sessions: MemorySessionStore(), ); await expectLater( client.get('/bookings', authenticated: false), throwsA( isA().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()), ); 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().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, ); 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); }); }); }