338 lines
14 KiB
Dart
338 lines
14 KiB
Dart
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);
|
||
});
|
||
});
|
||
}
|