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'; /// ───────────────────────────────────────────────────────────────────────── /// TWO LINES, ONE HANDSET, NO MIXING /// /// 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, tenant, line. /// ───────────────────────────────────────────────────────────────────────── void main() { const milkA = WorkScope(userId: 38, tenantId: 13, line: ServiceLine.milkMan); const logisticsA = WorkScope( userId: 38, tenantId: 13, line: ServiceLine.parcel, ); const milkB = WorkScope(userId: 99, tenantId: 13, line: ServiceLine.milkMan); const otherTenant = WorkScope( userId: 38, tenantId: 77, line: ServiceLine.milkMan, ); group('the key', () { test('rider, tenant and line each change the drawer', () { final keys = { milkA.scoped('completed_bookings'), logisticsA.scoped('completed_bookings'), milkB.scoped('completed_bookings'), otherTenant.scoped('completed_bookings'), }; expect( keys.length, 4, reason: 'all three identity parts must be in the key — two scopes ' 'sharing a key is the leak itself', ); expect( milkA.scoped('completed_bookings'), contains('completed_bookings'), ); }); test('the same session resolves to the same drawer', () { expect( milkA.scoped('skipped_bookings'), const WorkScope( userId: 38, tenantId: 13, line: ServiceLine.milkMan, ).scoped('skipped_bookings'), ); }); }); group('ownership', () { test('a milk-man record is not a logistics record', () { final row = milkA.stamp({'orderid': 'A1'}); expect(milkA.owns(row), isTrue); expect( logisticsA.owns(row), isFalse, reason: 'same rider, same tenant, other LINE — must not cross', ); expect(logisticsA.excludes(row), isTrue); }); test('another rider cannot claim it', () { final row = milkA.stamp({'orderid': 'A1'}); expect(milkB.owns(row), isFalse); expect(milkB.excludes(row), isTrue); }); test('another tenant cannot claim it', () { final row = milkA.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(milkA.excludes({'orderid': 'A1', 'mileruserid': 99}), isTrue); expect(milkA.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(milkA.owns(bare), isFalse); expect(milkA.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('switching line cannot expose the other line’s history', () async { SharedPreferences.setMockInitialValues({'userid': 38}); ServiceProfile.setActive(ServiceProfile.milkMan); await addCompletedBookings([ {'orderid': 'ROUND-1'}, ], terminalStatus: 'delivered'); expect( (await getCompletedBookings()).map((r) => r['orderid']), contains('ROUND-1'), ); // Same rider, same tenant, logistics day. ServiceProfile.setActive(ServiceProfile.parcel); expect( (await getCompletedBookings()).where((r) => r['orderid'] == 'ROUND-1'), isEmpty, reason: 'a milk-man delivery belongs only to milk-man Activity', ); // And a logistics collection stays out of the round's history. await addCompletedBookings([ {'orderid': 'PARCEL-1'}, ], terminalStatus: 'picked'); ServiceProfile.setActive(ServiceProfile.milkMan); expect( (await getCompletedBookings()).where((r) => r['orderid'] == 'PARCEL-1'), isEmpty, reason: 'a logistics pickup belongs only to logistics Activity', ); }); 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['scopeline'], ServiceLine.milkMan.name); }); 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('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'), ); }); }); }