Files
doormile_milderapp/test/tenant_mode_test.dart
Thiru-tenext d7348e253f Miler rider app: surface system, visible design language, backend lifecycle
Design system
- MilerSurface ladder (canvas → working → raised → floating) with MilerPanel
  as layer 1; canvas moved to #DEE3EA so white separates at 1.290:1.
- Visible vocabulary applied across Home, Deliveries, Activity, Account and
  the sheets: hero heads (tabular numeral + small caption, clamped at 1.3x),
  canvas wells for anything that opens, small filled tags for shelf labels,
  demoted placeholders. Recorded in DESIGN_SYSTEM.md §6.
- One icon family: 222 Material glyphs migrated to Lucide; none left outside
  lib/xpress.
- Colour semantics corrected: amber only for what is genuinely owed, brand red
  reserved for the live stop, disabled primaries go neutral rather than pale.

Data and lifecycle
- lib/data/lifecycle.dart reads mutations for what they prove; route_order.dart
  makes admin sequence the single ordering authority; service_day.dart, and
  stop_area.dart rewritten against live Coimbatore addresses (digit-token
  stripping, city stoplist, street suffixes, stammer collapse).
- countLabel states the load once, in bags.

Testing
- 1440 tests passing; golden shot harnesses for Home, Deliveries, Activity,
  sheets and verify, with test/failures/ now gitignored (diff debris).
- New pins: home_gutter_test, stop_area_test, plus updated structural bounds.

Note: this commit also carries pre-existing working-tree deletions that were
present before this work (API_SPEC.md, README.md, demo test fixtures).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 05:40:35 +05:30

289 lines
11 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);
});
});
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);
});
});
}