Files
doormile_milderapp/test/tenant_mode_test.dart
2026-08-28 15:07:30 +05:30

338 lines
14 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<ServiceProfile> resolveWith(Map<String, Object> 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<String, Object> claims) {
String seg(Map<String, Object> 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);
});
test('a known tenant id outranks a name that contradicts it', () async {
// ── The loose end in a one-shot global flag ──
//
// Tenant 13 is the meal client, and its display name is whatever was
// typed into the tenant record — plausibly the owning company's. A name
// normalising to `doormile` matched the Logistics list, won on
// name-first, and the id was never consulted.
//
// On Logistics a rider has no customer round — he carries what he
// collects to the hub — so nothing in the app calls `start-delivery` for
// him. With `MILER_COLLECTED_STATE_ENABLED` on, that rider's collected
// orders strand at `Collected_By_Miler` with nothing to release them.
for (final name in const ['Doormile', 'doormile', 'DOOR-MILE']) {
final p = await resolveWith({'tenantname': name, 'tenantid': 13});
expect(
p.line,
ServiceLine.milkMan,
reason: 'tenant 13 is the meal client whatever it is called ($name)',
);
}
});
test('a long legal name is harmless — it simply is not an answer', () async {
// The case the backend team asked about. It matches neither list, so it
// falls through to the id exactly as an empty name would.
expect(
TenantController.profileForName('Daily Grubs Foods Pvt Ltd'),
isNull,
);
final p = await resolveWith({
'tenantname': 'Daily Grubs Foods Pvt Ltd',
'tenantid': 13,
});
expect(p.line, ServiceLine.milkMan);
});
test('a tenant this build has never heard of is still decided by name',
() async {
// The property name-first existed for, and it must survive the change
// above: a NEW meal client, absent from the id list, costs no release.
final p = await resolveWith({'tenantname': 'milkrun', 'tenantid': 999});
expect(p.line, ServiceLine.milkMan);
// And the mirror — a new logistics client is not dragged onto the meal
// line by an id nobody has listed.
final q = await resolveWith({'tenantname': 'logistics', 'tenantid': 998});
expect(q.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);
});
});
}