updated the sheet
This commit is contained in:
353
test/duty_heartbeat_gate_test.dart
Normal file
353
test/duty_heartbeat_gate_test.dart
Normal file
@@ -0,0 +1,353 @@
|
||||
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/device_telemetry.dart';
|
||||
import 'package:miler/data/miler_api.dart';
|
||||
import 'package:miler/providers/Riderlog/riderlog_provider.dart';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// THE TELEMETRY NEVER LEFT THE PHONE
|
||||
///
|
||||
/// ── What the dispatcher saw ──
|
||||
///
|
||||
/// Battery, Charging, Connection, GPS and Location Service, all blank, on
|
||||
/// riders whose handsets were measuring every one of them correctly.
|
||||
///
|
||||
/// ── Where it actually stopped ──
|
||||
///
|
||||
/// Not in [DeviceTelemetry] — `device_telemetry_test.dart` pins that the
|
||||
/// reading and the payload are right. Not in the callers, which all spread
|
||||
/// `toPayload()` into the heartbeat. It stopped one layer further down, at a
|
||||
/// gate in `riderlog_provider.dart`:
|
||||
///
|
||||
/// ```
|
||||
/// if ((prefs.getInt('dutylogid') ?? 0) <= 0) return _startDuty(data);
|
||||
/// return _heartbeat(data);
|
||||
/// ```
|
||||
///
|
||||
/// `_heartbeat` is the **only** caller of `POST /miler/logs`, and also the only
|
||||
/// caller of `PUT /miler/location`. Both were unreachable whenever `dutylogid`
|
||||
/// was zero — and it stayed zero in two ordinary cases, permanently, with
|
||||
/// nothing anywhere reporting an error:
|
||||
///
|
||||
/// • `duty/start` returning a body that does not name the id under that exact
|
||||
/// key. The zero was then *stored*, so every later tick re-entered
|
||||
/// `_startDuty`.
|
||||
/// • `duty/start` answering 400 because the server already had the rider on
|
||||
/// duty. The reconciliation through `duty/current` recovered the duty state
|
||||
/// and wrote the id to `logid`/`logId` — never to `dutylogid`.
|
||||
///
|
||||
/// These tests drive the provider against a recording client, so what they
|
||||
/// assert is what the phone would actually put on the wire.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
void main() {
|
||||
late List<http.Request> sent;
|
||||
|
||||
/// The heartbeat payload a caller builds — the real shape, from
|
||||
/// `RiderLogController.createLoginNowV2` and the foreground service, both of
|
||||
/// which spread `DeviceTelemetry.toPayload()` into it.
|
||||
Map<String, dynamic> payload({int onduty = 1}) => {
|
||||
'userid': 90001,
|
||||
'latitude': '12.9716',
|
||||
'longitude': '77.5946',
|
||||
'speed': '0',
|
||||
'heading': '0',
|
||||
'accuracy': '8.5',
|
||||
'status': 'active',
|
||||
'onduty': onduty,
|
||||
...const DeviceTelemetry(
|
||||
battery: 72,
|
||||
isCharging: false,
|
||||
connection: 'wifi',
|
||||
locationService: 'enabled',
|
||||
).toPayload(),
|
||||
'is_background': false,
|
||||
};
|
||||
|
||||
/// Answers every route with a success envelope. [dutyStartBody] is what
|
||||
/// `POST /miler/duty/start` returns, which is the variable the bug turned on.
|
||||
void stub({
|
||||
Map<String, dynamic> dutyStartBody = const {},
|
||||
int dutyStartStatus = 200,
|
||||
Map<String, dynamic> dutyCurrentBody = const {'onduty': true},
|
||||
}) {
|
||||
sent = <http.Request>[];
|
||||
MilerApi.client = MockClient((req) async {
|
||||
sent.add(req);
|
||||
final path = req.url.path;
|
||||
Map<String, dynamic> body = const {};
|
||||
int status = 200;
|
||||
if (path.endsWith('/duty/start')) {
|
||||
body = dutyStartBody;
|
||||
status = dutyStartStatus;
|
||||
} else if (path.endsWith('/duty/current')) {
|
||||
body = dutyCurrentBody;
|
||||
}
|
||||
return http.Response(
|
||||
jsonEncode({'success': status < 400, 'data': body}),
|
||||
status,
|
||||
headers: const {'content-type': 'application/json'},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
List<http.Request> to(String suffix) =>
|
||||
sent.where((r) => r.url.path.endsWith(suffix)).toList();
|
||||
|
||||
Map<String, dynamic> bodyOf(http.Request r) =>
|
||||
jsonDecode(r.body) as Map<String, dynamic>;
|
||||
|
||||
/// `POST /miler/logs` is deliberately fire-and-forget inside `_heartbeat` — a
|
||||
/// telemetry failure must never read as a location failure, because the
|
||||
/// caller treats the latter as duty state going wrong. So the request is in
|
||||
/// flight, not finished, when the provider returns.
|
||||
Future<void> settle() => pumpEventQueue();
|
||||
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
stub();
|
||||
});
|
||||
|
||||
tearDown(() => MilerApi.client = http.Client());
|
||||
|
||||
group('the telemetry reaches POST /miler/logs', () {
|
||||
test(
|
||||
'every field the console draws is on the wire, under its own name',
|
||||
() async {
|
||||
// Duty already established, so this is an ordinary heartbeat tick.
|
||||
SharedPreferences.setMockInitialValues({'dutylogid': 4242});
|
||||
|
||||
await CreateRiderLogProvider().createRiderLog(payload());
|
||||
await settle();
|
||||
|
||||
final logs = to('/miler/logs');
|
||||
expect(logs, hasLength(1), reason: 'the heartbeat must post one log');
|
||||
|
||||
final body = bodyOf(logs.single);
|
||||
// The names are the wire's. A rename is a console column that goes blank
|
||||
// with nothing else failing.
|
||||
expect(body['battery'], '72');
|
||||
expect(body['is_charging'], false);
|
||||
expect(body['connection'], 'wifi');
|
||||
expect(body['location_service'], 'enabled');
|
||||
expect(body['accuracy'], '8.5');
|
||||
expect(body['is_background'], false);
|
||||
expect(body['latitude'], '12.9716');
|
||||
expect(body['longitude'], '77.5946');
|
||||
},
|
||||
);
|
||||
|
||||
test('and the location write still happens on the same tick', () async {
|
||||
// `PUT /miler/location` is the Redis geo-index dispatch searches. It
|
||||
// shares `_heartbeat` with the telemetry, so it was silenced by the same
|
||||
// gate — and it must not be lost while fixing the other half.
|
||||
SharedPreferences.setMockInitialValues({'dutylogid': 4242});
|
||||
|
||||
await CreateRiderLogProvider().createRiderLog(payload());
|
||||
await settle();
|
||||
|
||||
expect(to('/miler/location'), hasLength(1));
|
||||
expect(to('/miler/location').single.method, 'PUT');
|
||||
});
|
||||
|
||||
test(
|
||||
'REGRESSION · the log post completes before the heartbeat returns',
|
||||
() async {
|
||||
// ── Why this matters more than it looks ──
|
||||
//
|
||||
// `POST /miler/logs` was `unawaited(...)`, to keep a telemetry failure
|
||||
// from reading as a location failure. On Android the heartbeat runs
|
||||
// inside `flutter_foreground_task`'s own Flutter engine, spun up per
|
||||
// tick — and when the callback returns, that engine can be suspended
|
||||
// before an in-flight future finishes. So the awaited
|
||||
// `PUT /miler/location` landed every tick and this post was killed
|
||||
// mid-flight.
|
||||
//
|
||||
// The backend saw it before we did: a rider with a live position in
|
||||
// Redis and no telemetry row behind it, while the app logged a
|
||||
// successful heartbeat.
|
||||
//
|
||||
// Asserting *without* pumping the event queue is the whole point: if
|
||||
// the post ever goes back to being fire-and-forget, the request has not
|
||||
// been made yet at this line and this fails.
|
||||
SharedPreferences.setMockInitialValues({'dutylogid': 4242});
|
||||
|
||||
await CreateRiderLogProvider().createRiderLog(payload());
|
||||
|
||||
expect(
|
||||
to('/miler/logs'),
|
||||
hasLength(1),
|
||||
reason: 'the telemetry post must finish before the tick returns',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('no fix means no post, which is the contract, not a bug', () async {
|
||||
SharedPreferences.setMockInitialValues({'dutylogid': 4242});
|
||||
|
||||
await CreateRiderLogProvider().createRiderLog({
|
||||
...payload(),
|
||||
'latitude': '',
|
||||
'longitude': '',
|
||||
});
|
||||
await settle();
|
||||
|
||||
expect(to('/miler/logs'), isEmpty);
|
||||
expect(to('/miler/location'), isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('the gate that swallowed it', () {
|
||||
test(
|
||||
'REGRESSION · duty/start that does not name the id still heartbeats',
|
||||
() async {
|
||||
// The exact defect: an empty `data` body. The old code parsed 0, STORED
|
||||
// 0, and every later tick re-entered `_startDuty` — so `/miler/logs`
|
||||
// was never posted, on the rider's first shift, forever.
|
||||
stub(dutyStartBody: const {});
|
||||
|
||||
await CreateRiderLogProvider().createRiderLog(payload());
|
||||
await settle();
|
||||
|
||||
expect(to('/miler/duty/start'), hasLength(1));
|
||||
expect(
|
||||
to('/miler/logs'),
|
||||
hasLength(1),
|
||||
reason: 'starting duty must not cost the rider the tick that did it',
|
||||
);
|
||||
|
||||
// And the next tick is a plain heartbeat, not another start attempt.
|
||||
final before = to('/miler/duty/start').length;
|
||||
await CreateRiderLogProvider().createRiderLog(payload());
|
||||
await settle();
|
||||
expect(to('/miler/duty/start'), hasLength(before));
|
||||
expect(to('/miler/logs'), hasLength(2));
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'REGRESSION · "already on duty" reconciles AND opens the gate',
|
||||
() async {
|
||||
// `duty/start` 400s because the server already has him on duty — after
|
||||
// a reinstall, cleared storage, or an `endDuty` that never landed. The
|
||||
// reconciliation through `duty/current` used to write the id to
|
||||
// `logid`/`logId` and not to `dutylogid`, so the gate stayed shut and
|
||||
// the next tick came straight back here. Permanently.
|
||||
stub(
|
||||
dutyStartStatus: 400,
|
||||
dutyCurrentBody: const {'onduty': true, 'dutylogid': 77},
|
||||
);
|
||||
|
||||
await CreateRiderLogProvider().createRiderLog(payload());
|
||||
await settle();
|
||||
|
||||
expect(to('/miler/duty/current'), hasLength(1));
|
||||
expect(
|
||||
to('/miler/logs'),
|
||||
hasLength(1),
|
||||
reason: 'a reconciled rider must report, not loop on duty/start',
|
||||
);
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
expect(prefs.getInt('dutylogid'), 77);
|
||||
|
||||
// The loop is broken: no second start attempt.
|
||||
await CreateRiderLogProvider().createRiderLog(payload());
|
||||
await settle();
|
||||
expect(to('/miler/duty/start'), hasLength(1));
|
||||
expect(to('/miler/logs'), hasLength(2));
|
||||
},
|
||||
);
|
||||
|
||||
test('the id is found however the response spells it', () async {
|
||||
// One key on one level was the whole lookup. These are shapes this
|
||||
// contract has worn; a miss is no longer fatal, but the legacy `logid`
|
||||
// readers still want the number.
|
||||
for (final key in ['dutylogid', 'dutyLogId', 'logid', 'id']) {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
stub(dutyStartBody: {key: 501});
|
||||
|
||||
await CreateRiderLogProvider().createRiderLog(payload());
|
||||
await settle();
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
expect(
|
||||
prefs.getInt('dutylogid'),
|
||||
501,
|
||||
reason: 'the id was sent as `$key`',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('an install from before the flag keeps reporting', () async {
|
||||
// A rider mid-shift when he takes the update. He must not have to go off
|
||||
// duty and on again to start reporting, and he must not re-start duty.
|
||||
SharedPreferences.setMockInitialValues({'dutylogid': 909});
|
||||
|
||||
await CreateRiderLogProvider().createRiderLog(payload());
|
||||
await settle();
|
||||
|
||||
expect(to('/miler/duty/start'), isEmpty);
|
||||
expect(to('/miler/logs'), hasLength(1));
|
||||
});
|
||||
});
|
||||
|
||||
group('the update path is gated the same way', () {
|
||||
test(
|
||||
'an on-duty update establishes duty and beats on the same tick',
|
||||
() async {
|
||||
stub(dutyStartBody: const {});
|
||||
|
||||
await UpdateRiderLogProvider().updateRiderLog(payload());
|
||||
await settle();
|
||||
|
||||
expect(to('/miler/duty/start'), hasLength(1));
|
||||
expect(to('/miler/logs'), hasLength(1));
|
||||
},
|
||||
);
|
||||
|
||||
test('going off duty ends it and stops the heartbeat', () async {
|
||||
SharedPreferences.setMockInitialValues({'dutylogid': 4242});
|
||||
|
||||
await UpdateRiderLogProvider().updateRiderLog({
|
||||
...payload(onduty: 0),
|
||||
'onduty': 0,
|
||||
});
|
||||
await settle();
|
||||
|
||||
expect(to('/miler/duty/end'), hasLength(1));
|
||||
expect(
|
||||
to('/miler/logs'),
|
||||
isEmpty,
|
||||
reason: 'an off-duty payload is not a heartbeat',
|
||||
);
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
expect(prefs.getInt('dutylogid'), 0);
|
||||
|
||||
// And the next tick starts duty again rather than beating into a shift
|
||||
// that has ended.
|
||||
await CreateRiderLogProvider().createRiderLog(payload());
|
||||
await settle();
|
||||
expect(to('/miler/duty/start'), hasLength(1));
|
||||
});
|
||||
|
||||
test('duty/current reporting off duty closes the gate', () async {
|
||||
// The server is the authority. If it says the rider is off, the app must
|
||||
// not keep beating on a stale local flag.
|
||||
SharedPreferences.setMockInitialValues({'dutylogid': 4242});
|
||||
stub(dutyCurrentBody: const {'onduty': false});
|
||||
|
||||
await GetRiderLogProvider().getRiderLog();
|
||||
await settle();
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
expect(prefs.getInt('onduty'), 0);
|
||||
expect(prefs.getInt('dutylogid'), 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
291
test/heartbeat_interval_test.dart
Normal file
291
test/heartbeat_interval_test.dart
Normal file
@@ -0,0 +1,291 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'package:miler/controllers/riderlog.dart';
|
||||
import 'package:miler/data/heartbeat.dart';
|
||||
import 'package:miler/data/mock_backend.dart';
|
||||
import 'package:miler/providers/auth/auth_provider.dart';
|
||||
import 'package:miler/xpress/controllers/riderlog.dart' as xpress;
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// THE HEARTBEAT THAT NEVER STARTED
|
||||
///
|
||||
/// ── What the dispatcher saw ──
|
||||
///
|
||||
/// A rider on duty, and a console panel with an em dash against Battery,
|
||||
/// Charging, Connection, GPS Accuracy and Location Service. The obvious
|
||||
/// suspicion — that the phone was not measuring any of it — was wrong;
|
||||
/// `device_telemetry_test.dart` already pins that the reader and the payload
|
||||
/// are correct.
|
||||
///
|
||||
/// ── What was actually happening ──
|
||||
///
|
||||
/// One missing field, three call sites, and a zero that was read as an
|
||||
/// instruction instead of as an absence:
|
||||
///
|
||||
/// 1. `POST /miler/verify-pin` on the v1 contract answers with
|
||||
/// `{success, token, user:{…, profile:{…}}}`. There is no `logseconds` in
|
||||
/// it — that field belonged to the legacy login.
|
||||
/// 2. `AuthProvider.loginParsed` mapped the response into the legacy `details`
|
||||
/// shape and persisted `details['logseconds'] ?? 0`, so every rider's
|
||||
/// stored cadence was **zero**.
|
||||
/// 3. `setOnDuty` started the loop only `if (interval > 0)`, and
|
||||
/// `startAutoCreateLoginLoop` returned at `if (interval <= 0)`. Both gates
|
||||
/// were shut, on every rider, on every shift.
|
||||
///
|
||||
/// The loop that did not start is the only thing that calls `_heartbeat()`,
|
||||
/// and `_heartbeat()` is the only thing that writes **both** `POST /miler/logs`
|
||||
/// (the trail the console's device columns are drawn from) and
|
||||
/// `PUT /miler/location` (the geo-index dispatch searches to find a rider at
|
||||
/// all). So the silence was not only cosmetic.
|
||||
///
|
||||
/// Nothing threw and nothing logged an error, which is why it survived so long:
|
||||
/// the app reported duty on, posted exactly one log, and then went quiet for
|
||||
/// the rest of the shift.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
void main() {
|
||||
group('resolveLogSeconds — a cadence has a floor, not a zero', () {
|
||||
test('a real cadence from the hub is honoured exactly', () {
|
||||
// The legacy backend does send this, and a hub that tunes it must win.
|
||||
expect(resolveLogSeconds(15), 15);
|
||||
expect(resolveLogSeconds(120), 120);
|
||||
});
|
||||
|
||||
test('missing, zero and negative all mean "the backend did not say"', () {
|
||||
// The three shapes the bug actually arrived in. Zero is the one that
|
||||
// mattered: it is what `?? 0` wrote and what both gates read.
|
||||
expect(resolveLogSeconds(null), kDefaultLogSeconds);
|
||||
expect(resolveLogSeconds(0), kDefaultLogSeconds);
|
||||
expect(resolveLogSeconds(-30), kDefaultLogSeconds);
|
||||
});
|
||||
|
||||
test('a numeric string is a cadence too', () {
|
||||
// `details['logseconds']` is untyped JSON and has been seen as both.
|
||||
expect(resolveLogSeconds('45'), 45);
|
||||
expect(resolveLogSeconds(' 45 '), 45);
|
||||
expect(resolveLogSeconds('0'), kDefaultLogSeconds);
|
||||
expect(resolveLogSeconds('later'), kDefaultLogSeconds);
|
||||
expect(resolveLogSeconds(''), kDefaultLogSeconds);
|
||||
});
|
||||
|
||||
test('the floor is never zero, whatever it is handed', () {
|
||||
// The single property the whole fix rests on. Any return of 0 here puts
|
||||
// the old `interval <= 0` behaviour back, whatever the call sites say.
|
||||
for (final input in <Object?>[
|
||||
null,
|
||||
0,
|
||||
-1,
|
||||
'',
|
||||
'x',
|
||||
0.0,
|
||||
-0.5,
|
||||
<String>[],
|
||||
]) {
|
||||
expect(
|
||||
resolveLogSeconds(input),
|
||||
greaterThan(0),
|
||||
reason: 'resolveLogSeconds($input) must never disable the heartbeat',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('the default matches the cadence a live pickup forces', () {
|
||||
// Both controllers hard-code 30 for the live-pickup case, and the pickup
|
||||
// log's own interval falls back to 30. A different default here would
|
||||
// give one rider's trail two shapes depending on what he was doing.
|
||||
expect(kDefaultLogSeconds, 30);
|
||||
});
|
||||
});
|
||||
|
||||
group('the v1 login stores a cadence the loop can start on', () {
|
||||
setUp(() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
MockBackend.enabled = true;
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
MockBackend.enabled = false;
|
||||
});
|
||||
|
||||
test('verify-pin has no logseconds, so the adapter supplies the floor', () {
|
||||
// The mock's verify-pin body is the real v1 shape, verified against a
|
||||
// live 200 — `{success, token, user:{…, profile:{…}}}` and no cadence
|
||||
// anywhere in it. That absence is the whole bug.
|
||||
final canned = MockBackend.respond(
|
||||
'POST',
|
||||
'/miler/verify-pin',
|
||||
body: const {'phone': '9000000001', 'pin': '1234'},
|
||||
);
|
||||
expect(canned, isNotNull);
|
||||
expect(canned!.containsKey('logseconds'), isFalse);
|
||||
expect((canned['user'] as Map).containsKey('logseconds'), isFalse);
|
||||
});
|
||||
|
||||
test('the legacy details map carries 30, not 0', () async {
|
||||
final res = await AuthProvider().login(
|
||||
contactNo: '9000000001',
|
||||
deviceType: 'android',
|
||||
configId: 1,
|
||||
deviceId: 'test-device',
|
||||
fcmToken: '',
|
||||
pinRaw: '1234',
|
||||
);
|
||||
|
||||
final Map<String, dynamic> body = json.decode(res.body);
|
||||
final Map<String, dynamic> details = body['details'];
|
||||
|
||||
// Read back by `loginParsed` and written to prefs. A zero here is the
|
||||
// stored zero that shut both gates.
|
||||
expect(details['logseconds'], kDefaultLogSeconds);
|
||||
expect(details['logseconds'], greaterThan(0));
|
||||
});
|
||||
|
||||
test(
|
||||
'the stored cadence would pass even the gate that was removed',
|
||||
() async {
|
||||
// `setOnDuty` used to read this pref and compare it against zero. The
|
||||
// assertion is deliberately written as the *old* condition: it is the
|
||||
// one that decided whether a rider reported for his whole shift.
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt(
|
||||
'logseconds',
|
||||
resolveLogSeconds(null), // what the v1 adapter now supplies
|
||||
);
|
||||
|
||||
final int interval = resolveLogSeconds(prefs.getInt('logseconds'));
|
||||
expect(interval > 0, isTrue);
|
||||
expect(interval, 30);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('the loop starts when the backend never sent a cadence', () {
|
||||
setUp(() {
|
||||
MockBackend.enabled = true;
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
MockBackend.enabled = false;
|
||||
});
|
||||
|
||||
test('startAutoCreateLoginLoop runs with logseconds absent', () async {
|
||||
// Exactly the state a v1 rider is in after signing in and going on duty:
|
||||
// on duty, and no cadence stored at all.
|
||||
SharedPreferences.setMockInitialValues({'onduty': 1});
|
||||
final ctl = RiderLogController();
|
||||
addTearDown(ctl.stopAutoCreateLoginLoop);
|
||||
|
||||
await ctl.startAutoCreateLoginLoop();
|
||||
await pumpEventQueue();
|
||||
|
||||
// Before the fix this returned at `if (interval <= 0)` and left the
|
||||
// subscription null — duty on, nothing reporting.
|
||||
expect(
|
||||
ctl.autoLoopRunning,
|
||||
isTrue,
|
||||
reason: 'a rider on duty must beat even when the hub set no cadence',
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'and with logseconds explicitly zero, which is what was stored',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues({'onduty': 1, 'logseconds': 0});
|
||||
final ctl = RiderLogController();
|
||||
addTearDown(ctl.stopAutoCreateLoginLoop);
|
||||
|
||||
await ctl.startAutoCreateLoginLoop();
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(ctl.autoLoopRunning, isTrue);
|
||||
},
|
||||
);
|
||||
|
||||
test('off duty still does not beat', () async {
|
||||
// The fix removes the cadence gate, not the duty gate. A rider who is
|
||||
// off duty is not tracked, and that must stay true.
|
||||
SharedPreferences.setMockInitialValues({'onduty': 0});
|
||||
final ctl = RiderLogController();
|
||||
addTearDown(ctl.stopAutoCreateLoginLoop);
|
||||
|
||||
await ctl.startAutoCreateLoginLoop();
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(ctl.autoLoopRunning, isFalse);
|
||||
});
|
||||
|
||||
test('calling it again does not leave two loops running', () async {
|
||||
// Three places call this — going on duty, resuming the app, and a pickup
|
||||
// going live — and they overlap in practice. `stopAutoCreateLoginLoop`
|
||||
// runs first, so the last caller wins and there is never a second
|
||||
// subscription posting a duplicate trail.
|
||||
SharedPreferences.setMockInitialValues({'onduty': 1});
|
||||
final ctl = RiderLogController();
|
||||
addTearDown(ctl.stopAutoCreateLoginLoop);
|
||||
|
||||
await ctl.startAutoCreateLoginLoop();
|
||||
await pumpEventQueue();
|
||||
expect(ctl.autoLoopRunning, isTrue);
|
||||
|
||||
// The live-pickup path, which passes its own cadence.
|
||||
await ctl.startAutoCreateLoginLoop(seconds: 30);
|
||||
await pumpEventQueue();
|
||||
expect(ctl.autoLoopRunning, isTrue);
|
||||
|
||||
await ctl.stopAutoCreateLoginLoop();
|
||||
expect(
|
||||
ctl.autoLoopRunning,
|
||||
isFalse,
|
||||
reason: 'one stop must be enough to silence the heartbeat',
|
||||
);
|
||||
});
|
||||
|
||||
test('an explicit cadence from the caller is still honoured', () async {
|
||||
SharedPreferences.setMockInitialValues({'onduty': 1});
|
||||
final ctl = RiderLogController();
|
||||
addTearDown(ctl.stopAutoCreateLoginLoop);
|
||||
|
||||
await ctl.startAutoCreateLoginLoop(seconds: 45);
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(ctl.autoLoopRunning, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('the meal line has the same fix', () {
|
||||
setUp(() {
|
||||
MockBackend.enabled = true;
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
MockBackend.enabled = false;
|
||||
});
|
||||
|
||||
// `lib/xpress` is a parallel copy of the rider controller for the
|
||||
// cloud-kitchen line, switched by tenant. It carried the identical two
|
||||
// gates, so a meal-run rider was as silent as a logistics one.
|
||||
test('xpress starts its loop with no cadence stored', () async {
|
||||
SharedPreferences.setMockInitialValues({'onduty': 1});
|
||||
final ctl = xpress.RiderLogController();
|
||||
addTearDown(ctl.stopAutoCreateLoginLoop);
|
||||
|
||||
await ctl.startAutoCreateLoginLoop();
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(ctl.autoLoopRunning, isTrue);
|
||||
});
|
||||
|
||||
test('xpress still respects duty', () async {
|
||||
SharedPreferences.setMockInitialValues({'onduty': 0});
|
||||
final ctl = xpress.RiderLogController();
|
||||
addTearDown(ctl.stopAutoCreateLoginLoop);
|
||||
|
||||
await ctl.startAutoCreateLoginLoop();
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(ctl.autoLoopRunning, isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
251
test/miler_log_contract_test.dart
Normal file
251
test/miler_log_contract_test.dart
Normal file
@@ -0,0 +1,251 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
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/device_telemetry.dart';
|
||||
import 'package:miler/data/heartbeat.dart';
|
||||
import 'package:miler/data/miler_api.dart';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// THE `POST /miler/logs` CONTRACT, PINNED FIELD BY FIELD
|
||||
///
|
||||
/// The gate that stopped the heartbeat reaching this endpoint is fixed
|
||||
/// elsewhere (`duty_heartbeat_gate_test.dart`). This is the layer under it: of
|
||||
/// the requests that *do* go out, are they in the shape the handler parses?
|
||||
///
|
||||
/// Four of these were wrong, and every one of them fails silently. Nothing 400s
|
||||
/// in a way the app surfaces, the row is written or dropped server-side, and
|
||||
/// the only symptom is a console panel that is blank or stale:
|
||||
///
|
||||
/// 1. **`logdate` was IST wall-clock.** The handler parses it as UTC and uses
|
||||
/// it as a sort score, so every point from an Indian handset was stamped
|
||||
/// +05:30 into the future — outside any recent-window the console asks for,
|
||||
/// and filed under tomorrow after 18:30 local.
|
||||
/// 2. **`status` was `active` / `idle`.** Neither word appears in the API. The
|
||||
/// field takes an availability value, and this backend drops unrecognised
|
||||
/// enums silently.
|
||||
/// 3. **`connection` was the plugin's enum constant** — `wifi`, `mobile`,
|
||||
/// `ethernet` — not the contract's `WiFi` / `4G` / `none`.
|
||||
/// 4. **`location_service` could be `denied` or `denied_forever`**, which the
|
||||
/// field does not define.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('logdate is the phone\'s local wall clock', () {
|
||||
// ── Reverted from UTC, on the backend team's correction ──
|
||||
//
|
||||
// UTC was the intuitive answer for a field with no zone suffix, and it is
|
||||
// the wrong one for this backend: `DBNow` / `DBToday` and the database
|
||||
// itself run on IST wall-clock. A UTC stamp from an Indian handset lands
|
||||
// 5h30m behind the console's window, and after 18:30 local it files the
|
||||
// evening under the previous day.
|
||||
//
|
||||
// Written against local fields rather than a fixed string, so the suite
|
||||
// passes wherever it runs — including CI on a UTC host, which is precisely
|
||||
// the machine that would not have caught the original mistake.
|
||||
String two(int n) => n.toString().padLeft(2, '0');
|
||||
|
||||
test('a local DateTime is rendered exactly as its own wall clock', () {
|
||||
final at = DateTime(2026, 8, 27, 21, 30, 0);
|
||||
expect(MilerApi.logStamp(at), '2026-08-27 21:30:00');
|
||||
});
|
||||
|
||||
test('a UTC instant is converted to local, not printed as UTC', () {
|
||||
final utc = DateTime.utc(2026, 8, 27, 16, 0, 0);
|
||||
final local = utc.toLocal();
|
||||
expect(
|
||||
MilerApi.logStamp(utc),
|
||||
'${local.year}-${two(local.month)}-${two(local.day)} '
|
||||
'${two(local.hour)}:${two(local.minute)}:${two(local.second)}',
|
||||
);
|
||||
});
|
||||
|
||||
test('the evening does not roll into the previous day', () {
|
||||
// The case UTC actually broke: 21:30 IST is 16:00 UTC the same day, but
|
||||
// 00:30 IST is 19:00 UTC the day *before*. The date must follow the
|
||||
// rider's clock, because that is the day the console groups by.
|
||||
final at = DateTime(2026, 8, 27, 23, 45, 0);
|
||||
expect(MilerApi.logStamp(at), '2026-08-27 23:45:00');
|
||||
});
|
||||
|
||||
test('the format is what the handler parses, with no zone suffix', () {
|
||||
final stamp = MilerApi.logStamp(DateTime(2026, 1, 2, 3, 4, 5));
|
||||
expect(stamp, '2026-01-02 03:04:05');
|
||||
expect(stamp, matches(r'^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$'));
|
||||
});
|
||||
});
|
||||
|
||||
group('status is an availability value', () {
|
||||
test('both values the heartbeat can send are in the enum', () {
|
||||
for (final hasWork in [true, false]) {
|
||||
final status = heartbeatStatus(hasActiveWork: hasWork);
|
||||
expect(
|
||||
MilerApi.availabilityStatuses,
|
||||
contains(status),
|
||||
reason: '`$status` is not a value the backend accepts',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('the words that were there before are not in the enum', () {
|
||||
// Pinned so the old strings cannot come back through a merge.
|
||||
expect(MilerApi.availabilityStatuses, isNot(contains('active')));
|
||||
expect(MilerApi.availabilityStatuses, isNot(contains('idle')));
|
||||
});
|
||||
|
||||
test('a rider at a counter and a rider between stops differ', () {
|
||||
expect(heartbeatStatus(hasActiveWork: true), 'On_Pickup');
|
||||
expect(heartbeatStatus(hasActiveWork: false), 'Available');
|
||||
});
|
||||
});
|
||||
|
||||
group('the telemetry vocabulary is the contract, not the plugin', () {
|
||||
test('connection is WiFi / 4G / none', () {
|
||||
const t = DeviceTelemetry(connection: 'WiFi');
|
||||
expect(t.toPayload()['connection'], 'WiFi');
|
||||
});
|
||||
|
||||
test('location_service is only ever enabled or disabled', () async {
|
||||
// `read` collapses the richer answers the fix lookup produces. The
|
||||
// distinction between off and denied still rides on the MQTT alert.
|
||||
for (final raw in ['denied', 'denied_forever', 'unknown', 'disabled']) {
|
||||
final t = await DeviceTelemetry.read(locationService: raw);
|
||||
final v = t.toPayload()['location_service'];
|
||||
expect(
|
||||
v,
|
||||
anyOf(isNull, 'enabled', 'disabled'),
|
||||
reason: '`$raw` must not reach the wire as itself',
|
||||
);
|
||||
expect(v, isNot('denied'));
|
||||
}
|
||||
});
|
||||
|
||||
test('enabled survives, because it is the one that means yes', () async {
|
||||
final t = await DeviceTelemetry.read(locationService: 'enabled');
|
||||
expect(t.toPayload()['location_service'], 'enabled');
|
||||
});
|
||||
});
|
||||
|
||||
group('the request on the wire', () {
|
||||
late List<http.Request> sent;
|
||||
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
sent = <http.Request>[];
|
||||
MilerApi.client = MockClient((req) async {
|
||||
sent.add(req);
|
||||
return http.Response(
|
||||
jsonEncode({'success': true, 'data': {}}),
|
||||
200,
|
||||
headers: const {'content-type': 'application/json'},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
tearDown(() => MilerApi.client = http.Client());
|
||||
|
||||
Future<Map<String, dynamic>> post() async {
|
||||
await MilerApi.postLog(
|
||||
latitude: 12.9716,
|
||||
longitude: 77.5946,
|
||||
speed: 4.2,
|
||||
heading: 91.0,
|
||||
accuracy: 8.5,
|
||||
status: heartbeatStatus(hasActiveWork: true),
|
||||
orderId: 25,
|
||||
battery: 72,
|
||||
isCharging: true,
|
||||
connection: 'WiFi',
|
||||
locationService: 'enabled',
|
||||
isBackground: true,
|
||||
);
|
||||
return jsonDecode(sent.single.body) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
test('every numeric field is a string', () async {
|
||||
final body = await post();
|
||||
for (final key in [
|
||||
'latitude',
|
||||
'longitude',
|
||||
'speed',
|
||||
'heading',
|
||||
'accuracy',
|
||||
'battery',
|
||||
]) {
|
||||
expect(
|
||||
body[key],
|
||||
isA<String>(),
|
||||
reason: '`$key` must be a string — real numbers fail to parse',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('is_charging and is_background stay real booleans', () async {
|
||||
final body = await post();
|
||||
expect(body['is_charging'], isA<bool>());
|
||||
expect(body['is_background'], isA<bool>());
|
||||
expect(body['is_charging'], isTrue);
|
||||
expect(body['is_background'], isTrue);
|
||||
});
|
||||
|
||||
test('userid is never sent — the token owns it', () async {
|
||||
final body = await post();
|
||||
expect(body.containsKey('userid'), isFalse);
|
||||
expect(body.containsKey('userId'), isFalse);
|
||||
});
|
||||
|
||||
test(
|
||||
'one point per call, because the handler parses one MilerLog',
|
||||
() async {
|
||||
await post();
|
||||
expect(sent, hasLength(1));
|
||||
expect(jsonDecode(sent.single.body), isA<Map<String, dynamic>>());
|
||||
expect(
|
||||
jsonDecode(sent.single.body),
|
||||
isNot(isA<List>()),
|
||||
reason: 'this endpoint is not the batched one',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('the whole documented field set is present', () async {
|
||||
final body = await post();
|
||||
expect(
|
||||
body.keys,
|
||||
containsAll(<String>[
|
||||
'logdate',
|
||||
'latitude',
|
||||
'longitude',
|
||||
'speed',
|
||||
'heading',
|
||||
'accuracy',
|
||||
'status',
|
||||
'orderid',
|
||||
'battery',
|
||||
'is_charging',
|
||||
'connection',
|
||||
'location_service',
|
||||
'is_background',
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('the connectivity mapping', () {
|
||||
// Driven through `read` because the mapper is private; on the test host
|
||||
// the plugin is absent, so this pins the shape rather than the wiring.
|
||||
test('the enum this plugin version returns is a list, not a scalar', () {
|
||||
// connectivity_plus 6+ reports every active transport. Reading `.first`
|
||||
// made a phone on Wi-Fi with mobile data up report either one, depending
|
||||
// on the order the platform happened to return them.
|
||||
expect(ConnectivityResult.values, contains(ConnectivityResult.wifi));
|
||||
expect(ConnectivityResult.values, contains(ConnectivityResult.mobile));
|
||||
expect(ConnectivityResult.values, contains(ConnectivityResult.none));
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user