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

255 lines
8.0 KiB
Dart

import 'dart:async';
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/api_config.dart';
import 'package:miler/data/miler_api.dart';
import 'package:miler/data/mock_backend.dart';
import 'package:miler/data/mutation_guard.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// ONE PRESS, ONE REQUEST
///
/// Every business-critical action here is a POST the backend is not idempotent
/// about. A rider presses them on a moving bike, with gloves, on a screen he
/// cannot look at for long — so a double tap is not an edge case. Two
/// `duty/start` calls are a server error; two `payment` calls are two payments;
/// two `accept` calls race each other to decide what the list shows.
/// ─────────────────────────────────────────────────────────────────────────
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late int calls;
late Completer<void> release;
/// A server that does not answer until the test lets it, so a second press
/// genuinely lands while the first is still in flight.
void stubSlow({int code = 200, Map<String, dynamic>? body}) {
calls = 0;
release = Completer<void>();
MilerApi.client = MockClient((_) async {
calls++;
await release.future;
return http.Response(
json.encode(body ?? {'success': true}),
code,
headers: const {'content-type': 'application/json'},
);
});
}
setUp(() async {
MockBackend.enabled = false;
MutationGuard.reset();
SharedPreferences.setMockInitialValues({'authtoken': 'test-token'});
await ApiConfig.setToken('test-token');
});
tearDown(() {
MilerApi.client = http.Client();
MockBackend.enabled = kMockBackend;
MutationGuard.reset();
MilerApi.onUnauthorized = null;
});
test('a double tap on accept sends one request', () async {
stubSlow();
final first = MilerApi.acceptAssignment(1042);
final second = MilerApi.acceptAssignment(1042);
release.complete();
final results = await Future.wait([first, second]);
expect(calls, 1, reason: 'the second press must not reach the server');
expect(results.where((r) => r.ok).length, 1);
// ── The dropped press is a failure, not a silent null ──
//
// A caller that does not know about the guard still has to take its error
// path rather than reading nothing as success.
final dropped = results.firstWhere((r) => !r.ok);
expect(dropped.message, contains('already going through'));
});
test('two different assignments are not blocked by each other', () async {
stubSlow();
final a = MilerApi.acceptAssignment(1);
final b = MilerApi.acceptAssignment(2);
release.complete();
await Future.wait([a, b]);
expect(calls, 2, reason: 'the guard is keyed by resource, not by verb');
});
test(
'the key frees on completion, so a genuine retry goes through',
() async {
stubSlow();
final first = MilerApi.acceptAssignment(7);
release.complete();
await first;
stubSlow();
final retry = MilerApi.acceptAssignment(7);
release.complete();
await retry;
expect(calls, 1, reason: 'the second stub saw a fresh request');
},
);
test('a failed mutation frees its key too', () async {
// A guard that leaks on failure is worse than no guard: the rider's retry
// would be dropped for the rest of the session.
MilerApi.client = MockClient((_) async => throw const _Dead());
await MilerApi.pickupComplete(55);
expect(MutationGuard.isBusy('pickup-complete:55'), isFalse);
});
test('duty start and end share one key, in both directions', () async {
stubSlow();
final start = MilerApi.startDuty(lat: 11, lon: 76);
final end = MilerApi.endDuty();
release.complete();
await Future.wait([start, end]);
expect(
calls,
1,
reason:
'a rider tapping the switch again because the first tap "did '
'nothing" is the commonest way to double-fire duty',
);
});
group('every non-idempotent mutation is guarded', () {
Future<void> expectGuarded(
String label,
Future<dynamic> Function() call,
) async {
stubSlow();
final a = call();
final b = call();
release.complete();
await Future.wait([a, b]);
expect(calls, 1, reason: '$label is not protected against a double tap');
}
test('the pickup flow', () async {
await expectGuarded('reached', () => MilerApi.reached(9));
await expectGuarded('parcel', () => MilerApi.submitParcels(9, const []));
await expectGuarded(
'payment',
() => MilerApi.submitPayment(9, paymentMode: 'Cash', amount: 120),
);
await expectGuarded('pickup-complete', () => MilerApi.pickupComplete(9));
});
test('the delivery flow', () async {
await expectGuarded(
'deliver',
() => MilerApi.deliver(3, deliveredToName: 'Joe'),
);
await expectGuarded(
'skip',
() => MilerApi.skipConsignment(3, reason: 'Nobody home'),
);
});
test('availability and breaks', () async {
await expectGuarded(
'availability',
() => MilerApi.setAvailability('Available'),
);
await expectGuarded('break', () => MilerApi.startBreak('Personal'));
});
test('reject and cancel', () async {
await expectGuarded(
'reject',
() => MilerApi.rejectAssignment(4, reason: 'Too far'),
);
await expectGuarded(
'cancel',
() => MilerApi.cancelBooking(4, reason: 'Shop closed'),
);
});
});
group('a 401 ends the session, once, centrally', () {
test('the dead token is dropped and the app is told', () async {
var told = 0;
MilerApi.onUnauthorized = () => told++;
MilerApi.client = MockClient(
(_) async => http.Response(
json.encode({'success': false, 'message': 'token expired'}),
401,
headers: const {'content-type': 'application/json'},
),
);
final res = await MilerApi.getProfile();
expect(res.ok, isFalse);
expect(res.status, 401);
expect(told, 1);
expect(
await ApiConfig.getToken(),
isNull,
reason: 'nothing may retry with a token the server has refused',
);
});
test('a wrong PIN does not log anybody out', () async {
var told = 0;
MilerApi.onUnauthorized = () => told++;
MilerApi.client = MockClient(
(_) async => http.Response(
json.encode({'success': false, 'message': 'incorrect PIN'}),
401,
headers: const {'content-type': 'application/json'},
),
);
await MilerApi.verifyPin(phone: '9787698259', pin: '0000');
expect(
told,
0,
reason:
'a 401 on an auth route is about a credential, not an expired '
'session — clearing state here is a logout in response to a typo',
);
});
test('a 429 is a failure, not a session end', () async {
var told = 0;
MilerApi.onUnauthorized = () => told++;
MilerApi.client = MockClient(
(_) async => http.Response(
json.encode({'success': false, 'message': 'rate limited'}),
429,
headers: const {'content-type': 'application/json'},
),
);
final res = await MilerApi.assignments();
expect(res.ok, isFalse);
expect(res.status, 429);
expect(res.message, 'rate limited');
expect(told, 0);
expect(await ApiConfig.getToken(), isNotNull);
});
});
}
class _Dead implements Exception {
const _Dead();
}