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 sent; setUp(() { SharedPreferences.setMockInitialValues({}); sent = []; 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> 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; } 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(), 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()); expect(body['is_background'], isA()); 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>()); expect( jsonDecode(sent.single.body), isNot(isA()), 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([ '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)); }); }); }