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