Design system - MilerSurface ladder (canvas → working → raised → floating) with MilerPanel as layer 1; canvas moved to #DEE3EA so white separates at 1.290:1. - Visible vocabulary applied across Home, Deliveries, Activity, Account and the sheets: hero heads (tabular numeral + small caption, clamped at 1.3x), canvas wells for anything that opens, small filled tags for shelf labels, demoted placeholders. Recorded in DESIGN_SYSTEM.md §6. - One icon family: 222 Material glyphs migrated to Lucide; none left outside lib/xpress. - Colour semantics corrected: amber only for what is genuinely owed, brand red reserved for the live stop, disabled primaries go neutral rather than pale. Data and lifecycle - lib/data/lifecycle.dart reads mutations for what they prove; route_order.dart makes admin sequence the single ordering authority; service_day.dart, and stop_area.dart rewritten against live Coimbatore addresses (digit-token stripping, city stoplist, street suffixes, stammer collapse). - countLabel states the load once, in bags. Testing - 1440 tests passing; golden shot harnesses for Home, Deliveries, Activity, sheets and verify, with test/failures/ now gitignored (diff debris). - New pins: home_gutter_test, stop_area_test, plus updated structural bounds. Note: this commit also carries pre-existing working-tree deletions that were present before this work (API_SPEC.md, README.md, demo test fixtures). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
325 lines
11 KiB
Dart
325 lines
11 KiB
Dart
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
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/api_config.dart';
|
|
import 'package:miler/data/miler_api.dart';
|
|
import 'package:miler/data/mock_backend.dart';
|
|
|
|
/// ─────────────────────────────────────────────────────────────────────────
|
|
/// WHAT THIS CLIENT ACTUALLY PUTS ON THE WIRE
|
|
///
|
|
/// Several fields in this contract are quietly load-bearing, and every one of
|
|
/// them fails *silently* if it drifts — the request is accepted, or rejected
|
|
/// with a generic message, and nothing on the phone says which field was wrong:
|
|
///
|
|
/// • `device_token` is snake_case where everything around it is not.
|
|
/// • Telemetry numbers go as **strings**; real JSON numbers fail to parse.
|
|
/// • Telemetry bodies must carry **no `userid`** — identity is the token, and
|
|
/// a body `userid` is how one rider's GPS could be written under another's.
|
|
/// • Availability sends `Break`, not the obvious `On_Break`.
|
|
/// • `vehicle-required` takes **query parameters**, not a body.
|
|
/// • Consignment logs are a JSON **array**, even for one entry.
|
|
///
|
|
/// None of it was covered, because `_send` reached the `http` package's
|
|
/// top-level functions and could only be exercised by letting it hit the
|
|
/// network. `MilerApi.client` is the seam; these assert the request itself.
|
|
/// ─────────────────────────────────────────────────────────────────────────
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
late List<http.Request> sent;
|
|
|
|
/// Captures every request and answers with [body].
|
|
void stub({
|
|
Map<String, dynamic> body = const {'success': true},
|
|
int code = 200,
|
|
}) {
|
|
sent = <http.Request>[];
|
|
MilerApi.client = MockClient((req) async {
|
|
sent.add(req);
|
|
return http.Response(
|
|
json.encode(body),
|
|
code,
|
|
headers: const {'content-type': 'application/json'},
|
|
);
|
|
});
|
|
}
|
|
|
|
Map<String, dynamic> lastBody() =>
|
|
json.decode(sent.single.body) as Map<String, dynamic>;
|
|
|
|
setUp(() async {
|
|
// The canned backend intercepts *above* the HTTP client, so with it on
|
|
// there is no request to assert against — which is precisely why these
|
|
// fields went uncovered for so long.
|
|
MockBackend.enabled = false;
|
|
SharedPreferences.setMockInitialValues({'authtoken': 'test-token'});
|
|
await ApiConfig.setToken('test-token');
|
|
stub();
|
|
});
|
|
|
|
tearDown(() {
|
|
MilerApi.client = http.Client();
|
|
MockBackend.enabled = kMockBackend;
|
|
});
|
|
|
|
group('authentication', () {
|
|
test('every authenticated call carries the bearer token', () async {
|
|
await MilerApi.getProfile();
|
|
expect(sent.single.headers['Authorization'], 'Bearer test-token');
|
|
});
|
|
|
|
test(
|
|
'login and verify-pin do not, because there is nothing to carry yet',
|
|
() async {
|
|
await MilerApi.login('9787698259');
|
|
expect(sent.single.headers.containsKey('Authorization'), isFalse);
|
|
|
|
stub();
|
|
await MilerApi.verifyPin(phone: '9787698259', pin: '1234');
|
|
expect(sent.single.headers.containsKey('Authorization'), isFalse);
|
|
},
|
|
);
|
|
|
|
test('the rider surface is always partition 1001', () async {
|
|
await MilerApi.login('9787698259');
|
|
expect(lastBody()['configid'], 1001);
|
|
});
|
|
|
|
test('the PIN goes as a string, so a leading zero survives', () async {
|
|
await MilerApi.verifyPin(phone: '9787698259', pin: '0512');
|
|
expect(lastBody()['pin'], '0512');
|
|
expect(lastBody()['pin'], isA<String>());
|
|
});
|
|
|
|
// ── reset-pin is admin/ops, and the rider app must never reach it ──
|
|
//
|
|
// It was once open, and reset-pin followed by verify-pin took over any
|
|
// rider account given nothing but a phone number.
|
|
test('no source file in the app ever builds a reset-pin request', () {
|
|
// Asserted against the source rather than a stub, because the failure
|
|
// mode is somebody *adding* the call — and a runtime test only catches a
|
|
// route that is already being exercised. Comments explaining why it is
|
|
// absent are expected; a request path is not.
|
|
final offenders = <String>[];
|
|
for (final f in Directory('lib').listSync(recursive: true)) {
|
|
if (f is! File || !f.path.endsWith('.dart')) continue;
|
|
for (final line in f.readAsLinesSync()) {
|
|
final code = line.trim();
|
|
if (code.startsWith('//') || code.startsWith('///')) continue;
|
|
// The route, not the word: explaining in a log message why the app
|
|
// does not call it is exactly the right thing to do.
|
|
if (code.contains('/miler/reset-pin')) offenders.add(f.path);
|
|
}
|
|
}
|
|
expect(
|
|
offenders,
|
|
isEmpty,
|
|
reason:
|
|
'reset-pin needs an admin token; reset-pin followed by '
|
|
'verify-pin once took over any rider account given only a phone '
|
|
'number',
|
|
);
|
|
});
|
|
});
|
|
|
|
group('profile and device', () {
|
|
test('the device token field is snake_case', () async {
|
|
await MilerApi.setDeviceToken('fcm-abc');
|
|
expect(sent.single.method, 'PUT');
|
|
expect(sent.single.url.path, endsWith('/miler/device-token'));
|
|
expect(lastBody().keys, contains('device_token'));
|
|
expect(lastBody().keys, isNot(contains('deviceToken')));
|
|
expect(lastBody()['device_token'], 'fcm-abc');
|
|
});
|
|
});
|
|
|
|
group('duty and availability', () {
|
|
test(
|
|
'availability sends `status`, and the break value is `Break`',
|
|
() async {
|
|
await MilerApi.setAvailability('Break');
|
|
expect(lastBody()['status'], 'Break');
|
|
expect(
|
|
MilerApi.availabilityStatuses,
|
|
contains('Break'),
|
|
reason: 'the obvious guess, On_Break, is the wrong one',
|
|
);
|
|
expect(MilerApi.availabilityStatuses, isNot(contains('On_Break')));
|
|
},
|
|
);
|
|
|
|
test('ending duty and breaks are PUT, starting them is POST', () async {
|
|
await MilerApi.startDuty(lat: 11.0, lon: 76.9);
|
|
expect(sent.single.method, 'POST');
|
|
|
|
stub();
|
|
await MilerApi.endDuty();
|
|
expect(sent.single.method, 'PUT');
|
|
|
|
stub();
|
|
await MilerApi.endBreak();
|
|
expect(sent.single.method, 'PUT');
|
|
});
|
|
});
|
|
|
|
group('telemetry', () {
|
|
test('every numeric-looking field is serialized as a string', () async {
|
|
await MilerApi.postLog(
|
|
latitude: 11.0168,
|
|
longitude: 76.9558,
|
|
speed: 4.2,
|
|
heading: 180.0,
|
|
accuracy: 8.0,
|
|
battery: 73,
|
|
);
|
|
|
|
final b = lastBody();
|
|
for (final key in const [
|
|
'latitude',
|
|
'longitude',
|
|
'speed',
|
|
'heading',
|
|
'accuracy',
|
|
'battery',
|
|
]) {
|
|
expect(b[key], isA<String>(), reason: '$key must not be a JSON number');
|
|
}
|
|
expect(b['latitude'], '11.0168');
|
|
expect(b['battery'], '73');
|
|
});
|
|
|
|
test('no telemetry body carries a userid', () async {
|
|
await MilerApi.postLog(latitude: 1, longitude: 2);
|
|
expect(
|
|
lastBody().keys,
|
|
isNot(contains('userid')),
|
|
reason:
|
|
'identity is the token; a body userid is how one rider\'s GPS '
|
|
'gets written under another\'s',
|
|
);
|
|
|
|
stub();
|
|
await MilerApi.postStatus('Available');
|
|
expect(lastBody().keys, isNot(contains('userid')));
|
|
});
|
|
|
|
test('consignment logs are a JSON array, even for one entry', () async {
|
|
await MilerApi.postConsignmentLogs([
|
|
const ConsignmentLogEntry(
|
|
consignmentId: 42,
|
|
status: 'Out_for_Delivery',
|
|
latitude: 11.0,
|
|
longitude: 76.9,
|
|
),
|
|
]);
|
|
final decoded = json.decode(sent.single.body);
|
|
expect(decoded, isA<List<dynamic>>());
|
|
expect((decoded as List).single, isA<Map<String, dynamic>>());
|
|
});
|
|
|
|
test('location writes lat/lon/pincode/speed/heading', () async {
|
|
await MilerApi.pushLocation(
|
|
latitude: 11.0,
|
|
longitude: 76.9,
|
|
pincode: '641004',
|
|
speed: 3.0,
|
|
heading: 90.0,
|
|
);
|
|
expect(sent.single.method, 'PUT');
|
|
expect(sent.single.url.path, endsWith('/miler/location'));
|
|
expect(lastBody().keys, containsAll(['latitude', 'longitude']));
|
|
});
|
|
});
|
|
|
|
group('pickup and delivery', () {
|
|
test('vehicle-required uses query parameters, not a body', () async {
|
|
await MilerApi.vehicleRequired(
|
|
101,
|
|
type: 'Truck',
|
|
reason: 'Parcel too large for the box',
|
|
);
|
|
expect(
|
|
sent.single.url.queryParameters,
|
|
isNotEmpty,
|
|
reason:
|
|
'this one takes query parameters where its neighbours take '
|
|
'a JSON body',
|
|
);
|
|
expect(sent.single.body, anyOf(isEmpty, equals('null')));
|
|
});
|
|
|
|
test('pickup-complete is its own transition, not a status write', () async {
|
|
await MilerApi.pickupComplete(101);
|
|
expect(sent.single.method, 'POST');
|
|
expect(
|
|
sent.single.url.path,
|
|
endsWith('/miler/bookings/101/pickup-complete'),
|
|
);
|
|
});
|
|
|
|
test('a consignment is delivered and skipped by its own id', () async {
|
|
await MilerApi.deliver(
|
|
7,
|
|
deliveredToName: 'Joe Mathew',
|
|
lat: 11.0,
|
|
lon: 76.9,
|
|
);
|
|
expect(sent.single.url.path, endsWith('/miler/consignments/7/deliver'));
|
|
expect(lastBody()['deliveredtoname'], 'Joe Mathew');
|
|
// ── The OTP is conditional, and its absence is a real answer ──
|
|
//
|
|
// Nothing generates a delivery OTP, so the handler records whether one
|
|
// was presented rather than checking it. A caller without one — a
|
|
// milk-run drop handed over with no code — must not invent a placeholder.
|
|
expect(lastBody().keys, isNot(contains('otp')));
|
|
|
|
stub();
|
|
await MilerApi.skipConsignment(7, reason: 'Customer not available');
|
|
expect(sent.single.url.path, endsWith('/miler/consignments/7/skip'));
|
|
expect(lastBody()['reason'], 'Customer not available');
|
|
});
|
|
});
|
|
|
|
group('errors are values, not exceptions', () {
|
|
test(
|
|
'a rejected call comes back as a failed result with the message',
|
|
() async {
|
|
stub(body: {'success': false, 'message': 'incorrect PIN'}, code: 401);
|
|
final res = await MilerApi.getProfile();
|
|
|
|
expect(res.ok, isFalse);
|
|
expect(res.status, 401);
|
|
expect(res.message, 'incorrect PIN');
|
|
},
|
|
);
|
|
|
|
test('a body that is not JSON does not throw', () async {
|
|
MilerApi.client = MockClient(
|
|
(_) async => http.Response('<html>502 Bad Gateway</html>', 502),
|
|
);
|
|
final res = await MilerApi.getProfile();
|
|
expect(res.ok, isFalse);
|
|
expect(res.status, 502);
|
|
});
|
|
|
|
test('a transport failure is a result, not an uncaught throw', () async {
|
|
MilerApi.client = MockClient((_) async => throw const SocketFailure());
|
|
final res = await MilerApi.getProfile();
|
|
expect(res.ok, isFalse);
|
|
expect(res.status, 0);
|
|
});
|
|
});
|
|
}
|
|
|
|
/// Stands in for a dead network without depending on `dart:io` in a test that
|
|
/// otherwise needs none.
|
|
class SocketFailure implements Exception {
|
|
const SocketFailure();
|
|
}
|