import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:miler/data/api_config.dart'; import 'package:miler/data/service_profile.dart'; /// ───────────────────────────────────────────────────────────────────────── /// ONE LOGIN, ONE APP, THE TENANT DECIDES /// /// ``` /// phone ─▶ MPIN ─▶ verify-pin ─▶ tenantid / tenantname ─▶ ServiceProfile /// ╱ ╲ /// Milk Man Logistics /// ``` /// /// ── The direction this must fail in ── /// /// Every assertion below has a mirror. It is easy to prove a milk-man tenant /// gets the milk run; the half that breaks silently is a **logistics** rider /// losing his cash screen or gaining a kitchen heading, and nobody files a bug /// saying "the milk-man build looks fine". So an unknown tenant, a blank one and /// a missing one all land on logistics, which is the profile that asks for more /// rather than less. /// /// ── And the build flag no longer wins ── /// /// It used to be read first, because `verify-pin` returned no tenant and the /// flag was the only signal there was. Now the account answers, and "which work /// am I doing today?" belongs to the hub that rostered the rider rather than to /// whoever compiled the APK — one build serves every rider, so a flag that /// outranked the account meant one wrong build put *everyone* on the wrong flow. /// ───────────────────────────────────────────────────────────────────────── void main() { TestWidgetsFlutterBinding.ensureInitialized(); Future resolveWith(Map prefs) async { SharedPreferences.setMockInitialValues(prefs); return resolveServiceProfile(); } tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); group('the tenant name off the login', () { test('a milk-man tenant gets the milk run', () async { for (final name in [ 'milkman', 'milk man', 'milk-man', 'milkrun', 'milk run', ]) { final p = await resolveWith({'tenantname': name}); expect(p.line, ServiceLine.milkMan, reason: name); } }); test('the older food-client names still resolve', () async { // Existing accounts carry these. Dropping them would move live riders to // a different flow on an app update, which is not a rename anyone asked // for. for (final name in ['meals', 'dailygrubs']) { final p = await resolveWith({'tenantname': name}); expect(p.line, ServiceLine.milkMan, reason: name); } }); test('a logistics tenant gets logistics', () async { for (final name in ['doormile', 'logistics', 'parcel']) { final p = await resolveWith({'tenantname': name}); expect(p.line, ServiceLine.parcel, reason: name); } }); test('case and surrounding space do not matter', () async { // `auth_provider` lowercases and trims before it stores, and the resolver // does it again — a console entry of " MilkMan " must not silently put a // rider on the wrong flow. final p = await resolveWith({'tenantname': 'milkman'}); expect(p.line, ServiceLine.milkMan); }); }); group('the tenant id off the login', () { test('tenant 13 is the milk run', () async { // The live rider this was found with: Rajan A, userid 38, tenant 13. final p = await resolveWith({'tenantid': 13}); expect(p.line, ServiceLine.milkMan); }); }); group('the tenant claim on the session token', () { // ── The bug this group exists for ── // // A real login returned `{success, token, user:{...}}` with **no tenant in // the body at all** — and a token whose payload said `tenantid: 13`. The // app was being told the answer and could not hear it, so every rider // resolved to the fallback line and the milk run was unreachable on a real // account. /// A token whose payload carries [claims]. Signature is decorative: nothing /// client-side verifies it, and nothing may start to. String tokenWith(Map claims) { String seg(Map m) => base64Url.encode(utf8.encode(json.encode(m))).replaceAll('=', ''); return '${seg({'alg': 'HS256', 'typ': 'JWT'})}.${seg(claims)}.sig'; } test('reads the tenant a real token claims', () { expect( ApiConfig.tenantIdFromToken( tokenWith({'userid': 38, 'roleid': 5, 'tenantid': 13}), ), 13, ); }); test('an already-signed-in rider is resolved from his session', () async { // No tenant in prefs — exactly what an older build left behind. He must // not have to log out and back in to land on the right flow. final p = await resolveWith({ 'authtoken': tokenWith({'userid': 38, 'tenantid': 13}), }); expect(p.line, ServiceLine.milkMan); }); test('an upgraded install with a stale tenant 0 self-heals', () async { // The exact state a phone is in after upgrading from a build that wrote // `tenantid: 0` into prefs because the login body had no tenant. Zero is // not a tenant, so it must not be treated as one and must not stop the // fall-through to the live session's claim — otherwise the rider has to // log out and back in to reach the right flow, and nothing on screen // would tell him to. final p = await resolveWith({ 'tenantid': 0, 'tenantname': '', 'authtoken': tokenWith({'userid': 38, 'tenantid': 13}), }); expect(p.line, ServiceLine.milkMan); }); test('the persisted tenant still wins over the claim', () async { final p = await resolveWith({ 'tenantname': 'logistics', 'authtoken': tokenWith({'tenantid': 13}), }); expect( p.line, ServiceLine.parcel, reason: 'the stored pair is the fresher answer; the claim is a backstop', ); }); test('a junk token decides nothing and throws nothing', () async { for (final bad in ['', 'not-a-jwt', 'a.b', 'a.!!!!.c', 'a.e30.c']) { expect(ApiConfig.tenantIdFromToken(bad), 0, reason: bad); } expect(ApiConfig.tenantIdFromToken(null), 0); // And a session holding one falls through rather than failing to launch. final p = await resolveWith({'authtoken': 'not-a-jwt'}); expect(p.line, ServiceLine.parcel); }); test('a token claiming tenant 0 decides nothing', () async { expect(ApiConfig.tenantIdFromToken(tokenWith({'tenantid': 0})), 0); final p = await resolveWith({ 'authtoken': tokenWith({'tenantid': 0}), }); expect(p.line, ServiceLine.parcel); }); test('a token claiming an unknown tenant falls back safely', () async { final p = await resolveWith({ 'authtoken': tokenWith({'tenantid': 4242}), }); expect(p.line, ServiceLine.parcel); }); }); group('the safe fallback', () { test('an unknown tenant name is logistics', () async { final p = await resolveWith({'tenantname': 'someclient'}); expect(p.line, ServiceLine.parcel); }); test('an unknown tenant id is logistics', () async { final p = await resolveWith({'tenantid': 4242}); expect(p.line, ServiceLine.parcel); }); test('no tenant at all is logistics', () async { final p = await resolveWith({}); expect(p.line, ServiceLine.parcel); }); test('a blank tenant name is logistics', () async { final p = await resolveWith({'tenantname': ' '}); expect(p.line, ServiceLine.parcel); }); test('the fallback keeps the screens that ask for MORE', () async { // The worst case this way is a milk-run rider seeing a payment prompt he // can dismiss. The other way takes the cash screen away from a logistics // rider standing at a door with a customer waiting to pay. final p = await resolveWith({}); expect(p.collectsCash, isTrue); expect(p.capturesShipmentAddresses, isTrue); expect(p.needsVerification, isTrue); }); }); group('the name is checked before the id', () { test('a recognised name wins over an unrecognised id', () async { final p = await resolveWith({'tenantname': 'milkman', 'tenantid': 4242}); expect(p.line, ServiceLine.milkMan); }); test('an unrecognised name falls through to the id', () async { // The registry is keyed by name today, so this proves the fall-through // path exists for the day ops would rather pin a number. expect(TenantController.profileForName('nobody'), isNull); expect(TenantController.profileForId(4242), isNull); final p = await resolveWith({'tenantname': 'nobody', 'tenantid': 4242}); expect(p.line, ServiceLine.parcel); }); }); group('what each mode can and cannot do', () { test('the milk run has no shipment desk and takes no money', () { const p = ServiceProfile.milkMan; expect(p.collectsCash, isFalse); expect(p.capturesShipmentAddresses, isFalse); expect(p.pricesShipment, isFalse); expect(p.initiatesShipment, isFalse); expect(p.needsVerification, isFalse); }); test('the milk run collects at a source and delivers it itself', () { const p = ServiceProfile.milkMan; expect(p.sourceIsKitchen, isTrue); expect(p.bulkLoadProof, isTrue); expect(p.handsOffAtCollection, isTrue); expect(p.deliversToCustomer, isTrue); expect(p.startsDeliveryAfterFullLoad, isTrue); }); test('logistics runs the whole shipment desk', () { const p = ServiceProfile.parcel; expect(p.capturesShipmentAddresses, isTrue); expect(p.pricesShipment, isTrue); expect(p.initiatesShipment, isTrue); expect(p.collectsCash, isTrue); expect(p.needsVerification, isTrue); }); test('logistics has no kitchen and no round of its own', () { const p = ServiceProfile.parcel; expect(p.sourceIsKitchen, isFalse); expect(p.bulkLoadProof, isFalse); expect(p.startsDeliveryAfterFullLoad, isFalse); // Its shipment goes to the hub and somebody else delivers it, which is // why this line ends at a warehouse and the milk run ends at a door. expect(p.deliversToCustomer, isFalse); expect(p.handsOffAtCollection, isFalse); }); test('accepted work goes to a different place on each line', () { // The single rule §12 of the brief turns on. expect(ServiceProfile.parcel.handoffAt, HandoffPoint.accepted); expect(ServiceProfile.milkMan.handoffAt, HandoffPoint.collected); }); }); group('the old names still compile to the same thing', () { test('meals is milkMan', () { expect(identical(ServiceProfile.meals, ServiceProfile.milkMan), isTrue); expect(ServiceProfile.milkMan.isMeals, isTrue); expect(ServiceProfile.milkMan.isMilkMan, isTrue); }); test('parcel is logistics', () { expect(ServiceProfile.parcel.isParcel, isTrue); expect(ServiceProfile.parcel.isLogistics, isTrue); }); }); }