Miler rider app: surface system, visible design language, backend lifecycle
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>
This commit is contained in:
324
test/miler_api_contract_test.dart
Normal file
324
test/miler_api_contract_test.dart
Normal file
@@ -0,0 +1,324 @@
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user