import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:miler/data/api_config.dart'; import 'package:miler/data/mutation_guard.dart'; /// ───────────────────────────────────────────────────────────────────────── /// LOGGING OUT ENDS THE SESSION, NOT JUST THE ROUTE /// /// The logout handler cleared the scoped stores and the doorstep photos, wrote /// `logged_out = true`, and sent the rider to the sign-in screen. It did **not** /// clear the bearer token. So the app *looked* signed out — the flag kept him /// on the sign-in screen — while `authtoken` sat in SharedPreferences, still /// valid, until the next `verifyPinWithServer` happened to overwrite it. /// /// Two consequences, and the second is the one that matters on a shared /// handset: /// /// * anything that reads the token without consulting the flag — a background /// isolate, the notification handler, a heartbeat that outlives the route /// change — could keep making authenticated calls as the rider who left; /// * a phone handed to the next rider carried the previous one's credential on /// disk. /// /// Two tests, because the bug had two halves: the credential lifecycle (does /// clearing actually clear?) and the call site (does logout actually call it?). /// The first would have passed throughout — `clearToken` was never broken, /// nobody called it — which is exactly why the second one reads the source. /// ───────────────────────────────────────────────────────────────────────── void main() { TestWidgetsFlutterBinding.ensureInitialized(); group('the credential lifecycle', () { test('a cleared token does not come back', () async { SharedPreferences.setMockInitialValues({'authtoken': 'rider-38-session'}); expect(await ApiConfig.getToken(), 'rider-38-session'); await ApiConfig.clearToken(); expect( await ApiConfig.getToken(), isNull, reason: 'the next rider on this handset must start with no session', ); }); test('an empty token reads as no token, not as a session', () async { // `getToken` returns null for '' deliberately: a blank string is truthy // enough to pass a `!= null` check, and that is how a signed-out app ends // up sending `Authorization: Bearer `. SharedPreferences.setMockInitialValues({'authtoken': ''}); expect(await ApiConfig.getToken(), isNull); }); test('clearing an already-clear session is not an error', () async { // Logout runs it unconditionally, including on a handset that never // signed in — a crash there would strand the rider on the account screen. SharedPreferences.setMockInitialValues({}); await ApiConfig.clearToken(); expect(await ApiConfig.getToken(), isNull); }); test('the in-flight guard does not survive the session', () async { // A mutation still held when the rider signs out would have its key // dropped for the *next* session, silently refusing his first press. await MutationGuard.run('accept:1042', () async { expect(MutationGuard.isBusy('accept:1042'), isTrue); return true; }); MutationGuard.reset(); expect(MutationGuard.isBusy('accept:1042'), isFalse); }); }); group('the teardown itself', () { // Read from source. The steps are a sequence of calls with no return value // to assert on, and the regression class is a *missing call* — the cheapest // honest guard against a missing call is to look for it. // // These used to read Profilepage.dart. The teardown moved into // `endSession` when the 401 path needed to perform exactly the same steps; // the assertions followed it rather than being deleted. final source = File('lib/data/session.dart').readAsStringSync(); test('it clears the bearer token', () { expect( source, contains('ApiConfig.clearToken()'), reason: 'Ending a session must end the credential, not only the route. ' 'Without this the token outlives the rider on a shared handset.', ); }); test('it clears the scoped stores and the doorstep photos', () { expect(source, contains('clearScopedStores()')); expect(source, contains('ProofStore.clearScope()')); }); test('it raises the flag the launch path reads', () { // `app_bootstrap` and `main` both branch on this to decide the start // screen. Clearing the token without it would send a rider to the // dashboard with no credential. expect(source, contains("setBool('logged_out', true)")); }); test('it frees any mutation still holding a key', () { expect(source, contains('MutationGuard.reset()')); }); test('it drops the cached GPS fix', () { // [Geofence] reuses one live fix across a batch. A position is a fact // about a rider; the next person on this handset must not have his first // proximity check measured from where the last one was standing. expect(source, contains('Geofence.resetCache()')); }); }); group('both ways out of a session use it', () { // ── The half that was missing entirely ── // // A session ends two ways: the rider presses Log out, or the server stops // accepting his token. Only the first was implemented. // // `MilerApi` drops the token on any 401 and calls `onUnauthorized` — and // NOTHING EVER ASSIGNED IT. Three references in the whole codebase, all // inside miler_api.dart, zero assignments. So the credential was deleted // and nothing else was: the profile stayed on disk, `logged_out` stayed // false, and the app kept drawing a signed-in dashboard whose every call // came back `401 authorization header is required`. // // Found on a real handset — rider 23, name and tenant on screen, no // `authtoken` in SharedPreferences, Home / Deliveries / Activity and the // heartbeat all failing in a loop. The rider sees no jobs and concludes // there is no work today; nothing anywhere tells him to sign in again. test('the manual Log out delegates to the shared teardown', () { final source = File( 'lib/views/Dashboard/profile/Profilepage.dart', ).readAsStringSync(); expect( source, contains('endSession()'), reason: 'Log out must use the same teardown the 401 path uses, or the ' 'two drift and one of them stops clearing something.', ); }); test('the 401 handler is actually assigned', () { // The assertion that would have caught the shipped bug. `onUnauthorized` // being declared and called proves nothing — it was both, and null. final source = File('lib/main.dart').readAsStringSync(); expect( source, contains('MilerApi.onUnauthorized ='), reason: 'MilerApi calls onUnauthorized on every 401. Left unassigned, a ' 'rider whose token expires is dropped into a session that looks ' 'signed in and can never make a successful call again.', ); }); test('the 401 handler ends the session and opens sign-in', () { final source = File('lib/main.dart').readAsStringSync(); expect(source, contains('endSession()')); expect( source, contains('SignIn()'), reason: 'clearing the credential without moving the rider leaves him ' 'on a dashboard that cannot load anything', ); }); test('a burst of 401s tears down once, not once per call', () { // A 401 rarely arrives alone: the home poll, the deliveries queue and the // heartbeat can each get one within the same second. Without a latch the // rider is pushed at the sign-in screen three times, and GetX stacks // three routes he then has to dismiss. final source = File('lib/main.dart').readAsStringSync(); expect(source, contains('_signingOut')); }); }); }