import 'dart:async'; import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:miler/data/api_config.dart'; import 'package:miler/data/load_state.dart'; import 'package:miler/data/miler_api.dart'; import 'package:miler/data/mock_backend.dart'; import 'package:miler/data/service_profile.dart'; import 'package:miler/data/work_repository.dart'; /// ───────────────────────────────────────────────────────────────────────── /// ONE DAY'S WORK, FETCHED ONCE /// /// Home, Deliveries and Activity asked the same question independently: three /// timers, three copies, three ideas of what had been accepted. Switching tabs /// re-fetched; a rebuild re-fetched; accepting a stop on one screen left the /// other two showing the old answer until their own poll came round. /// /// These pin the three properties that make one copy safe to share. /// ───────────────────────────────────────────────────────────────────────── void main() { TestWidgetsFlutterBinding.ensureInitialized(); final repo = WorkRepository.instance; late int requests; Map booking(String id) => { 'bookingid': id, 'orderid': id, 'pickupcustomer': 'Stop $id', 'pickupaddress': '12 Main Rd, Coimbatore', 'status': 'Miler_Assigned', }; void stub({ List>? rows, Object? rawData, int code = 200, Object? error, Duration delay = Duration.zero, }) { requests = 0; MilerApi.client = MockClient((req) async { // ── Only the day's own request is counted ── // // The repository also asks `/miler/assignments` for the hub's stop // sequence (see `WorkRepository._stampSequence`). That is a different // endpoint answering a different question, and counting it here would // make the dedupe assertions below read 2 for one load — so the stub // answers it with an empty list and the counter stays on the property // these tests are actually about: **one `/miler/bookings` per load**. if (req.url.path.endsWith('/miler/assignments')) { return http.Response( json.encode({'success': true, 'data': const []}), 200, headers: const {'content-type': 'application/json'}, ); } requests++; if (delay > Duration.zero) await Future.delayed(delay); if (error != null) throw error; return http.Response( json.encode({ 'success': code < 400, 'data': rawData ?? rows ?? const [], }), code, headers: const {'content-type': 'application/json'}, ); }); } setUp(() async { MockBackend.enabled = false; ServiceProfile.setActive(ServiceProfile.parcel); SharedPreferences.setMockInitialValues({'authtoken': 't'}); await ApiConfig.setToken('t'); repo.resetForTest(); stub(); }); tearDown(() { MilerApi.client = http.Client(); MockBackend.enabled = kMockBackend; ServiceProfile.setActive(ServiceProfile.parcel); repo.resetForTest(); }); group('one request, however many askers', () { test('three screens mounting together produce one request', () async { stub(rows: [booking('A')], delay: const Duration(milliseconds: 20)); final results = await Future.wait([ repo.load(), repo.load(), repo.load(), ]); expect(requests, 1, reason: 'the 2nd and 3rd await the same future'); for (final r in results) { expect(r, isA>>>()); } }); test( 'a tab switch inside the freshness window does not re-fetch', () async { stub(rows: [booking('A')]); await repo.load(); await repo.load(); await repo.load(); expect(requests, 1); }, ); test('but a pull-to-refresh always asks', () async { stub(rows: [booking('A')]); await repo.load(); await repo.load(force: true); expect(requests, 2); }); test('a mutation invalidates, so the next read is authoritative', () async { stub(rows: [booking('A')]); await repo.load(); await repo.invalidate(); expect(requests, 2); }); }); // ── The classic pull-to-refresh regression ── // // A slow earlier request finishing after a fast later one used to overwrite // it, and the list visibly went backwards. test('a late response cannot overwrite a newer one', () async { var call = 0; final gate = Completer(); MilerApi.client = MockClient((req) async { if (req.url.path.endsWith('/miler/assignments')) { return http.Response( json.encode({'success': true, 'data': const []}), 200, headers: const {'content-type': 'application/json'}, ); } call++; final isFirst = call == 1; if (isFirst) await gate.future; // the slow one return http.Response( json.encode({ 'success': true, 'data': [booking(isFirst ? 'STALE' : 'FRESH')], }), 200, headers: const {'content-type': 'application/json'}, ); }); // Two forced loads: a pull-to-refresh does not join a request that started // before the gesture, so this really is two sequences racing. final slow = repo.load(force: true); final fast = repo.load(force: true); gate.complete(); await Future.wait([slow, fast]); final value = repo.state.valueOrNull; expect(value, isNotNull); expect( value!.single['orderid'], 'FRESH', reason: 'the slow response landed last and must have been dropped', ); }); group('the three empties are three answers', () { test('nothing today is empty, not a failure', () async { stub(rows: const []); final s = await repo.load(); expect(s, isA>>>()); }); test('a milk-man rider is asked for, not written off', () async { // The regression this replaces: the milk-man line was believed to have no // endpoint, so this returned [LoadUnavailable] after **zero** requests — // and a rider on tenant 13 whom the hub had assigned a booking saw an // empty Home, an empty Bookings tab and an empty Activity, with no error // anywhere to explain it. The hub dispatches that line through the same // bookings the console assigns, so the request has to be made. stub(rows: [booking('MILK-1')]); ServiceProfile.setActive(ServiceProfile.milkMan); repo.resetForTest(); final s = await repo.load(); expect(s, isA>>>()); expect(s.valueOrNull?.single['orderid'], 'MILK-1'); expect(requests, 1, reason: 'his work has to be asked for to arrive'); }); test('a dead connection is offline, and retryable', () async { stub(error: const _Dead()); final s = await repo.load() as LoadFailure>>; expect(s.kind, LoadFailureKind.offline); expect(s.kind.isRetryable, isTrue); }); test( 'a 401 is unauthorized, and offering a retry would be a lie', () async { stub(code: 401); final s = await repo.load() as LoadFailure>>; expect(s.kind, LoadFailureKind.unauthorized); expect(s.kind.isRetryable, isFalse); }, ); test('a 429 is rate limited, not a server fault', () async { stub(code: 429); final s = await repo.load() as LoadFailure>>; expect(s.kind, LoadFailureKind.rateLimited); }); test( 'rows that arrive and do not map are a fault, not an empty day', () async { // A payload whose rows are not objects at all. The adapter maps every // Map it is given, so this is what a genuine shape mismatch looks like — // and it renders as "no work today" if you only count the mapped list, // which is the difference between waiting and reporting a broken feed. stub(rawData: const ['not', 'objects']); final s = await repo.load(); expect(s, isA>>>()); }, ); }); test('a refresh keeps the current data on screen', () async { stub(rows: [booking('A')]); await repo.load(); stub(rows: [booking('A')], delay: const Duration(milliseconds: 30)); final seen = >>>[]; final sub = repo.changes.listen(seen.add); await repo.load(force: true); await sub.cancel(); final duringRefresh = seen.first; expect(duringRefresh, isA>>>()); expect( (duringRefresh as LoadData).refreshing, isTrue, reason: 'a pull to refresh must not blank the list it is refreshing', ); }); test('every listener sees the same answer', () async { stub(rows: [booking('A')]); final a = >>>[]; final b = >>>[]; final subA = repo.changes.listen(a.add); final subB = repo.changes.listen(b.add); await repo.load(); // Let the broadcast drain before cancelling — a stream event is delivered // on a microtask, so cancelling in the same turn drops the last one. await Future.delayed(Duration.zero); await subA.cancel(); await subB.cancel(); // Both listeners saw the same sequence of the same objects: there is one // copy of the day in memory, not one per screen. expect(a, hasLength(b.length)); for (var i = 0; i < a.length; i++) { expect(identical(a[i], b[i]), isTrue, reason: 'event $i differed'); } expect(a.last.valueOrNull, isNotNull); expect(identical(a.last, repo.state), isTrue); }); } class _Dead implements Exception { const _Dead(); }