Files
doormile_milderapp/test/miler_api_contract_test.dart
Thiru-tenext d7348e253f 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>
2026-08-22 05:40:35 +05:30

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();
}