Files
doormile_milderapp/test/delivery_proof_test.dart
Thiru-tenext d612916fe4 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
2026-09-18 11:05:40 +05:30

172 lines
6.5 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:miler/data/proof_store.dart';
import 'package:miler/data/service_profile.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// PROOF OF DELIVERY
///
/// A photo taken at a door is evidence, and evidence that disappears is worse
/// than none — it makes the rider believe he has cover he does not have.
/// These pin the two properties that make it real: it outlives the OS cache
/// `ImagePicker` hands back, and it belongs to the session that took it.
/// ─────────────────────────────────────────────────────────────────────────
void main() {
late Directory docs;
setUp(() async {
ServiceProfile.setActive(ServiceProfile.milkMan);
SharedPreferences.setMockInitialValues({'userid': 38});
// path_provider has no host under the test binding.
docs = await Directory.systemTemp.createTemp('proof_docs');
TestWidgetsFlutterBinding.ensureInitialized();
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel('plugins.flutter.io/path_provider'),
(call) async => docs.path,
);
});
tearDown(() async {
ServiceProfile.setActive(ServiceProfile.parcel);
if (await docs.exists()) await docs.delete(recursive: true);
});
/// A real 1×1 PNG. The store tests never decode it, but the widget test
/// does: `Image.file` on 64 bytes of nonsense never completes its frame, so
/// `pumpAndSettle` waits for a decode that will never land and the test
/// hangs for the full ten minutes rather than failing.
const List<int> onePixelPng = [
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, //
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52,
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4,
0x89, 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44, 0x41,
0x54, 0x78, 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00,
0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00,
0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE,
0x42, 0x60, 0x82,
];
/// Stands in for what the camera hands back: a file in a volatile place.
Future<File> cameraShot(String name) async {
final cache = await Directory.systemTemp.createTemp('picker_cache');
final f = File('${cache.path}/$name.jpg');
await f.writeAsBytes(onePixelPng);
return f;
}
group('the store', () {
test('the photo is copied out of the camera cache', () async {
final shot = await cameraShot('a');
final saved = await ProofStore.save('ORDER-1', shot.path);
expect(saved, isNotNull);
expect(
saved,
isNot(shot.path),
reason:
'ImagePicker returns an OS cache path the system reclaims — '
'a record pointing at it loses its evidence',
);
expect(File(saved!).existsSync(), isTrue);
// The original can vanish and the proof survives, which is the point.
await shot.parent.delete(recursive: true);
expect(File(saved).existsSync(), isTrue);
expect(await ProofStore.pathFor('ORDER-1'), saved);
});
test('a retake replaces rather than accumulates', () async {
final first = await ProofStore.save(
'ORDER-1',
(await cameraShot('a')).path,
);
final second = await ProofStore.save(
'ORDER-1',
(await cameraShot('b')).path,
);
expect(second, first, reason: 'one stop, one proof');
expect(await ProofStore.pathFor('ORDER-1'), second);
});
test('a proof whose file has gone is not proof', () async {
final saved = await ProofStore.save(
'ORDER-1',
(await cameraShot('a')).path,
);
await File(saved!).delete();
expect(
await ProofStore.pathFor('ORDER-1'),
isNull,
reason: 'returning a dead path would draw a broken image as evidence',
);
});
test('nothing is invented from nothing', () async {
expect(await ProofStore.save('', 'whatever'), isNull);
expect(await ProofStore.save('ORDER-1', ''), isNull);
expect(await ProofStore.save('ORDER-1', '/no/such/file.jpg'), isNull);
expect(await ProofStore.pathFor('ORDER-NONE'), isNull);
});
test('a device path is never offered as a remote url', () async {
await ProofStore.save('ORDER-1', (await cameraShot('a')).path);
expect(
await ProofStore.remoteUrlFor('ORDER-1'),
'',
reason:
'the Miler API has no upload route; a filesystem path written '
"into the hub's photourl would look like evidence and resolve to "
'nothing',
);
});
});
group('ownership', () {
test("another rider cannot open this rider's doorstep photos", () async {
await ProofStore.save('ORDER-1', (await cameraShot('a')).path);
expect(await ProofStore.pathFor('ORDER-1'), isNotNull);
SharedPreferences.setMockInitialValues({'userid': 99});
expect(
await ProofStore.pathFor('ORDER-1'),
isNull,
reason: 'proof is scoped like every other record — see WorkScope',
);
});
test('but the same rider can, whatever line he is reading', () async {
// Reversed 2026-09-16 with the removal of the line from [WorkScope].
// A doorstep photo is evidence the rider produced; hiding it from him
// because a tenant label resolved differently after a refresh is how a
// proof goes missing at exactly the moment somebody disputes a delivery.
//
// The ownership rule above is untouched and is the one that matters.
await ProofStore.save('ORDER-1', (await cameraShot('a')).path);
ServiceProfile.setActive(ServiceProfile.parcel);
expect(await ProofStore.pathFor('ORDER-1'), isNotNull);
});
test('logout deletes the photos, not just the index', () async {
final saved = await ProofStore.save(
'ORDER-1',
(await cameraShot('a')).path,
);
await ProofStore.clearScope();
expect(await ProofStore.pathFor('ORDER-1'), isNull);
expect(
File(saved!).existsSync(),
isFalse,
reason: "a doorstep photo must not outlive the session that took it",
);
});
});
}