Three fixes found by running the app on a real handset against production. 1. An expired token left the app looking signed in and unable to work. MilerApi.onUnauthorized was declared and called on every 401 but never assigned, so the token was dropped and nothing else happened: the profile stayed on disk, logged_out stayed false, and the rider saw his own name over a dashboard whose every call returned 401. He reads that as "no work today". The teardown now lives in endSession() and both ways out of a session — the Log out button and the 401 path — use it. 2. Arrived was written locally even when the rider was not there. updateArrivedStatus answers false for three different things and the caller treated all of them as "the write did not land", which is only true of one. A geofence refusal and a server refusal now stop the rung and hand back the reason; a dead network still advances, as it should. 3. A multi-destination customer pickup collapsed onto one stop. GET /miler/bookings returns a row per destination once collected, all with the same bookingid and reference. Every local store keys on that id, so the accepted store deduped two of three drops away and their consignment ids were unrecoverable. orderid is now the stop key; bookingreference stays the booking's name. Cards show "Stop 2 of 3" and the receiver's own name and number rather than the sender's. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
304 lines
12 KiB
Dart
304 lines
12 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
import 'package:miler/data/accepted_store.dart';
|
|
import 'package:miler/data/service_profile.dart';
|
|
import 'package:miler/data/work_scope.dart';
|
|
|
|
/// ─────────────────────────────────────────────────────────────────────────
|
|
/// ONE RIDER, ONE DRAWER
|
|
///
|
|
/// Miler runs a milk-man round and a logistics day off one app and one login,
|
|
/// and it kept finished work under *global* SharedPreferences keys —
|
|
/// `completed_bookings` and friends. One phone, one drawer, whoever wrote
|
|
/// last: log out and back in as another rider, or move tenant, and the
|
|
/// previous scope's history was sitting in the new session's Activity.
|
|
///
|
|
/// These pin the ownership rule at the data boundary. Nothing here filters on
|
|
/// a display string — no kitchen names, no "Milk", no screen titles — only on
|
|
/// identity the session can prove: rider and tenant.
|
|
///
|
|
/// ── The line came OUT of the key, deliberately ──
|
|
///
|
|
/// It used to be a third part: `completed_bookings::u38.t13.milkMan`. That was
|
|
/// right while a rider was one kind of rider for the life of his account. He is
|
|
/// not. One Miler carries meal parcels, logistics collections and customer
|
|
/// pickups in the same shift, and keying his records by line split one day
|
|
/// across two drawers — so work he finished an hour ago vanished when the app
|
|
/// resolved him differently. Two tests below used to assert that split and now
|
|
/// assert the opposite; they are the record of the rule changing.
|
|
/// ─────────────────────────────────────────────────────────────────────────
|
|
void main() {
|
|
const riderA = WorkScope(userId: 38, tenantId: 13);
|
|
const riderB = WorkScope(userId: 99, tenantId: 13);
|
|
const otherTenant = WorkScope(userId: 38, tenantId: 77);
|
|
|
|
group('the key', () {
|
|
test('rider and tenant each change the drawer', () {
|
|
final keys = {
|
|
riderA.scoped('completed_bookings'),
|
|
riderB.scoped('completed_bookings'),
|
|
otherTenant.scoped('completed_bookings'),
|
|
};
|
|
expect(
|
|
keys.length,
|
|
3,
|
|
reason:
|
|
'both identity parts must be in the key — two scopes sharing a '
|
|
'key is the leak itself',
|
|
);
|
|
expect(
|
|
riderA.scoped('completed_bookings'),
|
|
contains('completed_bookings'),
|
|
);
|
|
});
|
|
|
|
test('the line is no longer part of the drawer', () {
|
|
expect(
|
|
riderA.key,
|
|
isNot(contains('milkMan')),
|
|
reason: 'one rider, one day, one drawer — whatever he is carrying',
|
|
);
|
|
for (final legacy in riderA.legacyScopedKeys('completed_bookings')) {
|
|
expect(legacy, isNot(riderA.scoped('completed_bookings')));
|
|
expect(legacy, startsWith('completed_bookings::u38.t13.'));
|
|
}
|
|
});
|
|
|
|
test('the same session resolves to the same drawer', () {
|
|
expect(
|
|
riderA.scoped('skipped_bookings'),
|
|
const WorkScope(userId: 38, tenantId: 13).scoped('skipped_bookings'),
|
|
);
|
|
});
|
|
});
|
|
|
|
group('ownership', () {
|
|
test('one rider owns his record whatever he was carrying', () {
|
|
// This asserted the opposite once: same rider, same tenant, other line
|
|
// "must not cross". Under one-Miler that rule loses him his own work —
|
|
// a meal drop and a parcel collection in the same shift are both his.
|
|
final row = riderA.stamp({'orderid': 'A1'});
|
|
expect(riderA.owns(row), isTrue);
|
|
expect(riderA.excludes(row), isFalse);
|
|
});
|
|
|
|
test('another rider cannot claim it', () {
|
|
final row = riderA.stamp({'orderid': 'A1'});
|
|
expect(riderB.owns(row), isFalse);
|
|
expect(riderB.excludes(row), isTrue);
|
|
});
|
|
|
|
test('another tenant cannot claim it', () {
|
|
final row = riderA.stamp({'orderid': 'A1'});
|
|
expect(otherTenant.owns(row), isFalse);
|
|
expect(otherTenant.excludes(row), isTrue);
|
|
});
|
|
|
|
test('the API stamps its own identity and it is honoured', () {
|
|
// Rows straight off `/miler/assignments` carry the rider; no scope stamp
|
|
// is involved and it must still be respected.
|
|
expect(riderA.excludes({'orderid': 'A1', 'mileruserid': 99}), isTrue);
|
|
expect(riderA.excludes({'orderid': 'A1', 'mileruserid': 38}), isFalse);
|
|
});
|
|
|
|
test('silence is read differently by the two questions', () {
|
|
// A row with no identity: `owns` refuses to adopt it (used on legacy
|
|
// rows, where inventing ownership IS the leak), `excludes` keeps it
|
|
// (used on rows the API just returned for this session).
|
|
final bare = {'orderid': 'A1'};
|
|
expect(riderA.owns(bare), isFalse);
|
|
expect(riderA.excludes(bare), isFalse);
|
|
});
|
|
});
|
|
|
|
group('the store, scoped', () {
|
|
setUp(() => ServiceProfile.setActive(ServiceProfile.milkMan));
|
|
tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
|
|
|
|
String today() {
|
|
final n = DateTime.now();
|
|
return '${n.year}-${n.month.toString().padLeft(2, '0')}-'
|
|
'${n.day.toString().padLeft(2, '0')}';
|
|
}
|
|
|
|
test(
|
|
'completed work written as one rider is invisible to the next',
|
|
() async {
|
|
// Rider 38 finishes a stop.
|
|
SharedPreferences.setMockInitialValues({'userid': 38});
|
|
await addCompletedBookings([
|
|
{'orderid': 'MILK-1', 'pickupcustomer': 'Joe'},
|
|
], terminalStatus: 'delivered');
|
|
final mine = await getCompletedBookings();
|
|
expect(mine.map((r) => r['orderid']), contains('MILK-1'));
|
|
|
|
// Rider 99 signs in on the same handset. Same day, same store, same
|
|
// process — a different drawer.
|
|
SharedPreferences.setMockInitialValues({'userid': 99});
|
|
final theirs = await getCompletedBookings();
|
|
expect(
|
|
theirs.where((r) => r['orderid'] == 'MILK-1'),
|
|
isEmpty,
|
|
reason: "logging in must never expose the previous rider's history",
|
|
);
|
|
},
|
|
);
|
|
|
|
test('a mixed shift is one history, not two', () async {
|
|
// The inverse of what this asserted before. One Miler collects meals and
|
|
// parcels in the same shift; his Activity is his day, and a label the app
|
|
// resolved him with must not hide half of it.
|
|
SharedPreferences.setMockInitialValues({'userid': 38});
|
|
ServiceProfile.setActive(ServiceProfile.milkMan);
|
|
await addCompletedBookings([
|
|
{'orderid': 'ROUND-1'},
|
|
], terminalStatus: 'delivered');
|
|
|
|
ServiceProfile.setActive(ServiceProfile.parcel);
|
|
await addCompletedBookings([
|
|
{'orderid': 'PARCEL-1'},
|
|
], terminalStatus: 'picked');
|
|
|
|
final ids = (await getCompletedBookings())
|
|
.map((r) => r['orderid'])
|
|
.toList();
|
|
expect(
|
|
ids,
|
|
containsAll(<String>['ROUND-1', 'PARCEL-1']),
|
|
reason: 'both were finished by rider 38 on tenant 13, in one shift',
|
|
);
|
|
|
|
// And it still holds from the other side.
|
|
ServiceProfile.setActive(ServiceProfile.milkMan);
|
|
expect(
|
|
(await getCompletedBookings()).map((r) => r['orderid']),
|
|
containsAll(<String>['ROUND-1', 'PARCEL-1']),
|
|
);
|
|
});
|
|
|
|
test('a stored record carries the identity that produced it', () async {
|
|
SharedPreferences.setMockInitialValues({'userid': 38});
|
|
await addCompletedBookings([
|
|
{'orderid': 'STAMP-1'},
|
|
], terminalStatus: 'delivered');
|
|
final row = (await getCompletedBookings()).single;
|
|
expect(row['scopeuserid'], 38);
|
|
expect(
|
|
row.containsKey('scopeline'),
|
|
isFalse,
|
|
reason: 'the line is not part of ownership any more',
|
|
);
|
|
});
|
|
|
|
test('logout empties this scope', () async {
|
|
SharedPreferences.setMockInitialValues({'userid': 38});
|
|
await addCompletedBookings([
|
|
{'orderid': 'BYE-1'},
|
|
], terminalStatus: 'delivered');
|
|
expect(await getCompletedBookings(), isNotEmpty);
|
|
|
|
await clearScopedStores();
|
|
expect(
|
|
await getCompletedBookings(),
|
|
isEmpty,
|
|
reason: 'signing out must not leave records for the next rider',
|
|
);
|
|
});
|
|
|
|
test('work stored under the old line-suffixed key is not lost', () async {
|
|
// The upgrade a real rider takes mid-shift. His finished stops were
|
|
// written to `completed_bookings::u38.t13.milkMan`; the key no longer has
|
|
// that suffix. Dropping them would lose work he actually did, and unlike
|
|
// a global key these rows are NOT anonymous — the key itself names him.
|
|
final today = DateTime.now();
|
|
final stamp =
|
|
'${today.year}-${today.month.toString().padLeft(2, '0')}-'
|
|
'${today.day.toString().padLeft(2, '0')}';
|
|
|
|
SharedPreferences.setMockInitialValues({
|
|
'userid': 38,
|
|
'completed_bookings::u38.t0.milkMan': jsonEncode([
|
|
{'orderid': 'OLD-MILK', 'completedday': stamp},
|
|
]),
|
|
'completed_bookings::u38.t0.parcel': jsonEncode([
|
|
{'orderid': 'OLD-PARCEL', 'completedday': stamp},
|
|
]),
|
|
});
|
|
|
|
// The migration has to be *run*. It is not lazy — `getCompletedBookings`
|
|
// reads the scoped key and nothing else — and this test called the reader
|
|
// without the migrator, so it was asserting against a drawer nobody had
|
|
// filled yet. `main()` awaits this on startup, before the first frame.
|
|
await migrateLegacyStores();
|
|
|
|
final rows = await getCompletedBookings();
|
|
expect(
|
|
rows.map((r) => r['orderid']),
|
|
containsAll(<String>['OLD-MILK', 'OLD-PARCEL']),
|
|
reason: 'both old drawers belong to rider 38 — merge, never drop',
|
|
);
|
|
|
|
// And the old keys are gone, so a second run cannot duplicate them.
|
|
final prefs = await SharedPreferences.getInstance();
|
|
expect(prefs.containsKey('completed_bookings::u38.t0.milkMan'), isFalse);
|
|
expect(prefs.containsKey('completed_bookings::u38.t0.parcel'), isFalse);
|
|
expect(
|
|
(await getCompletedBookings())
|
|
.where((r) => r['orderid'] == 'OLD-MILK')
|
|
.length,
|
|
1,
|
|
reason: 'the merge must be idempotent',
|
|
);
|
|
});
|
|
|
|
test('unattributable legacy rows are dropped, not adopted', () async {
|
|
// The pre-scoping global key, holding somebody's rows. Nothing on them
|
|
// says whose, so adopting them would be inventing ownership.
|
|
SharedPreferences.setMockInitialValues({
|
|
'userid': 38,
|
|
'completed_bookings': jsonEncode([
|
|
{'orderid': 'LEGACY-1', 'completedday': today()},
|
|
]),
|
|
});
|
|
|
|
await migrateLegacyStores();
|
|
|
|
expect(
|
|
(await getCompletedBookings()).where((r) => r['orderid'] == 'LEGACY-1'),
|
|
isEmpty,
|
|
reason: 'a row that cannot prove ownership must not be adopted',
|
|
);
|
|
final prefs = await SharedPreferences.getInstance();
|
|
expect(
|
|
prefs.containsKey('completed_bookings'),
|
|
isFalse,
|
|
reason: 'the global key is drained so it can never be read again',
|
|
);
|
|
});
|
|
|
|
test('a legacy row that proves ownership is carried across', () async {
|
|
SharedPreferences.setMockInitialValues({
|
|
'userid': 38,
|
|
'completed_bookings': jsonEncode([
|
|
{
|
|
'orderid': 'LEGACY-MINE',
|
|
'completedday': today(),
|
|
'mileruserid': 38,
|
|
'scopeline': 'milkMan',
|
|
},
|
|
]),
|
|
});
|
|
|
|
await migrateLegacyStores();
|
|
|
|
expect(
|
|
(await getCompletedBookings()).map((r) => r['orderid']),
|
|
contains('LEGACY-MINE'),
|
|
);
|
|
});
|
|
});
|
|
}
|