Files
doormile_milderapp/test/parcel_photos_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

214 lines
8.3 KiB
Dart

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/miler_api.dart';
import 'package:miler/providers/pickuplog/pickuplog_provider.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// THE DOOR PHOTOGRAPH REACHES THE HUB
///
/// `POST /miler/bookings/:id/parcel` has accepted `photos` for as long as the
/// route has existed. The server's own note says why it is there: *"Weight
/// without a photograph is a number the customer has no way to check, and this
/// is the only point in the flow where anyone is standing next to the parcel."*
///
/// The app never sent it — and not by oversight in one place, but through three
/// separate defects that each had to be fixed:
///
/// 1. `ParcelEntry.toJson()` had no `photos` key at all.
/// 2. `uploadProof` kept the public **URL** from the sign response and threw
/// away the **key**, which is what `photos` is specified in.
/// 3. The pickup upload passed a **booking** id in the `consignmentid` slot,
/// so every pickup proof this app ever uploaded was filed under a
/// consignment number that did not exist.
///
/// Meanwhile `StopVerificationPage` *compels* the photograph — the rider cannot
/// confirm the stop without one. So the app demanded the single piece of
/// evidence that settles a dispute, uploaded it, and dropped the reference.
/// ─────────────────────────────────────────────────────────────────────────
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('ParcelEntry serialisation', () {
test('photos are sent when there are any', () {
final json = const ParcelEntry(
weight: 2.5,
photos: ['pickup_proof/pickup_proof-4821-20260916-101402-a1b2.jpg'],
).toJson();
expect(json['photos'], isA<List>());
expect(
json['photos'],
['pickup_proof/pickup_proof-4821-20260916-101402-a1b2.jpg'],
);
expect(json['weight'], 2.5);
});
test('the key is a storage key, never a URL', () {
// The customer is served a short-lived signed link *derived from* the
// key. A URL stored here either expires or, worse, never does.
final json = const ParcelEntry(
weight: 1,
photos: ['pickup_proof/pickup_proof-4821-20260916-101402-a1b2.jpg'],
).toJson();
final photo = (json['photos'] as List).single.toString();
expect(photo, isNot(startsWith('http')));
expect(photo, isNot(contains('://')));
expect(photo, contains('/'), reason: 'a key is {folder}/{file}');
});
test('no photo means the field is OMITTED, not sent empty', () {
// An empty array is a claim that no photograph was taken. A failed
// upload is not that claim, and the difference matters to whoever reads
// the record afterwards.
final json = const ParcelEntry(weight: 1).toJson();
expect(json.containsKey('photos'), isFalse);
});
test('a local filesystem path is never what gets sent', () {
// The shape the bug produced: `parcelImage` held `/data/user/0/…/x.jpg`
// and nothing ever turned it into a reference the server could resolve.
final json = const ParcelEntry(weight: 1).toJson();
expect(json.toString(), isNot(contains('/data/user/')));
expect(json.toString(), isNot(contains('.jpg')));
});
});
group('the parcel request that actually leaves the phone', () {
late List<http.Request> sent;
setUp(() {
SharedPreferences.setMockInitialValues({'userid': 38, 'authtoken': 't'});
sent = <http.Request>[];
MilerApi.client = MockClient((req) async {
sent.add(req);
return http.Response('{"success":true,"data":{}}', 200);
});
});
tearDown(() => MilerApi.client = http.Client());
Map<String, dynamic> bodyOf(http.Request r) =>
jsonDecode(r.body) as Map<String, dynamic>;
test('photos ride on the wire to /parcel', () async {
await MilerApi.submitParcels(4821, const [
ParcelEntry(weight: 2, photos: ['pickup_proof/p-4821-x.jpg']),
ParcelEntry(weight: 2),
]);
expect(sent, hasLength(1));
expect(sent.single.url.path, endsWith('/miler/bookings/4821/parcel'));
final parcels = bodyOf(sent.single)['parcels'] as List;
expect(parcels, hasLength(2));
expect((parcels[0] as Map)['photos'], ['pickup_proof/p-4821-x.jpg']);
expect(
(parcels[1] as Map).containsKey('photos'),
isFalse,
reason: 'one photograph of the load, not one claimed per box',
);
});
test('the sign call keys a PRE-pickup proof on the BOOKING', () async {
// There is no consignment yet — `pickup-complete` has not run. The server
// builds the storage key from consignmentid, then bookingid, then the
// rider; passing a booking id in the consignment slot filed the proof
// under a consignment number that did not exist.
await MilerApi.signUpload(
purpose: MilerApi.proofPickup,
bookingId: 4821,
);
final body = bodyOf(sent.single);
expect(body['bookingid'], 4821);
expect(
body.containsKey('consignmentid'),
isFalse,
reason: 'a booking id must never be sent as a consignment id',
);
expect(body['purpose'], 'pickup_proof');
});
test('a delivery proof still keys on the consignment', () async {
// The other half of the contract, unchanged — `deliver` takes a URL and
// its proof belongs to a consignment that exists by then.
await MilerApi.signUpload(
purpose: MilerApi.proofDelivery,
consignmentId: 77,
);
final body = bodyOf(sent.single);
expect(body['consignmentid'], 77);
expect(body.containsKey('bookingid'), isFalse);
});
});
group('the provider turns a verification map into parcels', () {
late List<http.Request> sent;
setUp(() {
SharedPreferences.setMockInitialValues({'userid': 38, 'authtoken': 't'});
sent = <http.Request>[];
MilerApi.client = MockClient((req) async {
sent.add(req);
return http.Response('{"success":true,"data":{}}', 200);
});
});
tearDown(() => MilerApi.client = http.Client());
test('a key on the verification map reaches the first parcel', () async {
await UpdatePickupProvider().submitParcels(4821, const {
'pickup': {
'collected': 3,
'weight': '6',
'photos': ['pickup_proof/p-4821-x.jpg'],
},
});
final parcels =
(jsonDecode(sent.single.body) as Map)['parcels'] as List;
expect(parcels, hasLength(3), reason: 'three boxes were collected');
expect((parcels[0] as Map)['photos'], ['pickup_proof/p-4821-x.jpg']);
expect((parcels[1] as Map).containsKey('photos'), isFalse);
expect((parcels[2] as Map).containsKey('photos'), isFalse);
// The weight is still split evenly across the three — unchanged.
expect((parcels[0] as Map)['weight'], 2.0);
});
test('a failed upload still records the parcels', () async {
// A dead object store must not strand a rider at a door holding parcels
// he cannot record. The pickup goes through; the gap is logged.
await UpdatePickupProvider().submitParcels(4821, const {
'pickup': {'collected': 1, 'weight': '2'},
});
expect(sent, hasLength(1), reason: 'the parcels were still submitted');
final parcels =
(jsonDecode(sent.single.body) as Map)['parcels'] as List;
expect((parcels[0] as Map).containsKey('photos'), isFalse);
});
test('a blank key is dropped rather than sent', () async {
await UpdatePickupProvider().submitParcels(4821, const {
'pickup': {
'collected': 1,
'weight': '2',
'photos': ['', ' '],
},
});
final parcels =
(jsonDecode(sent.single.body) as Map)['parcels'] as List;
expect((parcels[0] as Map).containsKey('photos'), isFalse);
});
});
}