252 lines
9.5 KiB
Dart
252 lines
9.5 KiB
Dart
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));
|
|
});
|
|
});
|
|
}
|