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(['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(['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(['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'), ); }); }); }