Session expiry, arrival geofence guard, multi-destination stops

Three fixes found by running the app on a real handset against production.

1. An expired token left the app looking signed in and unable to work.
   MilerApi.onUnauthorized was declared and called on every 401 but never
   assigned, so the token was dropped and nothing else happened: the profile
   stayed on disk, logged_out stayed false, and the rider saw his own name over
   a dashboard whose every call returned 401. He reads that as "no work today".
   The teardown now lives in endSession() and both ways out of a session — the
   Log out button and the 401 path — use it.

2. Arrived was written locally even when the rider was not there.
   updateArrivedStatus answers false for three different things and the caller
   treated all of them as "the write did not land", which is only true of one.
   A geofence refusal and a server refusal now stop the rung and hand back the
   reason; a dead network still advances, as it should.

3. A multi-destination customer pickup collapsed onto one stop.
   GET /miler/bookings returns a row per destination once collected, all with
   the same bookingid and reference. Every local store keys on that id, so the
   accepted store deduped two of three drops away and their consignment ids
   were unrecoverable. orderid is now the stop key; bookingreference stays the
   booking's name. Cards show "Stop 2 of 3" and the receiver's own name and
   number rather than the sender's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
This commit is contained in:
2026-09-18 11:05:40 +05:30
parent 127fa062ed
commit d612916fe4
53 changed files with 6346 additions and 748 deletions

View File

@@ -0,0 +1,185 @@
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(<String, Object>{});
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'));
});
});
}