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()); 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 sent; setUp(() { SharedPreferences.setMockInitialValues({'userid': 38, 'authtoken': 't'}); sent = []; MilerApi.client = MockClient((req) async { sent.add(req); return http.Response('{"success":true,"data":{}}', 200); }); }); tearDown(() => MilerApi.client = http.Client()); Map bodyOf(http.Request r) => jsonDecode(r.body) as Map; 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 sent; setUp(() { SharedPreferences.setMockInitialValues({'userid': 38, 'authtoken': 't'}); sent = []; 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); }); }); }