The role split was right; only its source was wrong. Signing in matched what was typed against two constants compiled into the app — admin@nearle.in and cashier@nearle.in — so which shell a person got was a property of the *build*. A shop could not add a third person, revoke either of the two it had, or stop anyone with the APK reading both passwords out of it. TerminalLogin survives unchanged in shape, because the shape was the good part: one flag the shell reads, a session that decides it, and a cashier sign-out that takes the catalogue with it while a supervisor's leaves it behind. Every consumer — visibleModulesProvider, resolvedModuleProvider, the sidebar, the page header, the sign-out dialog — is untouched. What changed is that the enum is now only constructible from a session the back office signed, so there is no path left where the terminal grants itself a permission the server did not send. It reads `can_manage_staff` rather than the role name or id. app_roles holds six rows for four distinct roles, a great many accounts carry a roleid that is not in the table at all, and the name comes back blank for most of them. Matching on either would mean shipping a copy of the role table in the app and keeping the two in step for ever. One boolean, decided server-side, cannot drift. It defaults to false, which matters on the restore path: a session saved by a build that predates the field comes back as a cashier, never silently as an admin. This also restores the sign-in layer itself — pos_auth_api, pos_session, session_store, the staff import and the bearer token — which an earlier commit removed wholesale from a stale checkout. Its parent was the commit that added them, so the deletion was a bad merge rather than a decision; the terminal has been running on the two constants since. The login screen loses its role tabs and its credential prefill. You do not choose what you are on the way in. The opener is now matched on the back office user id rather than on the first account with a matching role, so the first bill of a shift is attributed to whoever actually signed in. Tests: the smoke suite pinned only the supervisor shell, and it was passing for the wrong reason — the fake session omitted can_manage_staff, and the sidebar it asserted on was there because the role was hardcoded. Both halves are pinned now and the fake is parameterised. widget_test.dart was the stock Flutter counter template, restored by the same bad merge, testing a MyApp that has never existed in this repo. 292 tests pass; analyzer reports no errors and no warnings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
209 lines
7.1 KiB
Dart
209 lines
7.1 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:google_fonts/google_fonts.dart';
|
|
import 'package:nearle_pos/app/providers.dart';
|
|
import 'package:nearle_pos/data/datasources/local_store.dart';
|
|
import 'package:nearle_pos/data/datasources/seed_data.dart';
|
|
import 'package:nearle_pos/domain/entities/store_account.dart';
|
|
import 'package:nearle_pos/presentation/auth/providers/auth_controller.dart';
|
|
import 'package:nearle_pos/presentation/modules/widgets/staff_dialogs.dart';
|
|
import 'package:nearle_pos/presentation/modules/widgets/store_details_dialog.dart';
|
|
|
|
/// The two admin screens that change what a till is and who may use it.
|
|
///
|
|
/// Both guard on role, and both refuse input that would break something a shop
|
|
/// cannot recover from on its own — a locked-out account, or a GSTIN that is
|
|
/// wrong on every invoice printed after it.
|
|
void main() {
|
|
setUpAll(() {
|
|
GoogleFonts.config.allowRuntimeFetching = false;
|
|
LocalStore.registerSeed(
|
|
products: SeedData.products,
|
|
customers: SeedData.customers,
|
|
);
|
|
});
|
|
|
|
setUp(() async {
|
|
await LocalStore.instance.reset(withCatalogue: true);
|
|
});
|
|
|
|
const admin = StaffUser(id: 'u1', name: 'Suriya', role: StaffRole.admin);
|
|
const cashier = StaffUser(id: 'u3', name: 'Rahul', role: StaffRole.cashier);
|
|
|
|
StoreAccount storeWith(List<StaffUser> staff) => StoreAccount(
|
|
id: 'store-001',
|
|
name: 'Nearle Daily',
|
|
email: 'admin@nearle.in',
|
|
address: '1 Test Street',
|
|
gstin: '33AABCU9603R1ZM',
|
|
phone: '9840000000',
|
|
staff: staff,
|
|
);
|
|
|
|
/// Mounts a dialog with [who] signed in.
|
|
Future<void> open(
|
|
WidgetTester tester, {
|
|
required StaffUser who,
|
|
required Future<void> Function(BuildContext) show,
|
|
List<StaffUser> staff = const [admin, cashier],
|
|
}) async {
|
|
await tester.pumpWidget(
|
|
ProviderScope(
|
|
overrides: [
|
|
storeAccountProvider.overrideWith((ref) async => storeWith(staff)),
|
|
authControllerProvider.overrideWith(
|
|
(ref) => _StubAuth(storeWith(staff), who),
|
|
),
|
|
],
|
|
child: MaterialApp(
|
|
home: Builder(
|
|
builder: (context) => Scaffold(
|
|
body: TextButton(
|
|
onPressed: () => show(context),
|
|
child: const Text('open'),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
await tester.tap(find.text('open'));
|
|
await tester.pumpAndSettle();
|
|
}
|
|
|
|
group('staff', () {
|
|
testWidgets('a cashier is refused', (tester) async {
|
|
// Anyone who can edit staff can make themselves an admin, so the check
|
|
// has to be at the door rather than on each action.
|
|
await open(tester, who: cashier, show: showStaffDialog);
|
|
|
|
expect(find.textContaining('Only an admin'), findsOneWidget);
|
|
expect(find.text('Add staff member'), findsNothing);
|
|
});
|
|
|
|
testWidgets('an admin can manage staff', (tester) async {
|
|
await open(tester, who: admin, show: showStaffDialog);
|
|
|
|
expect(find.text('Add staff member'), findsOneWidget);
|
|
expect(find.text('Suriya'), findsOneWidget);
|
|
expect(find.text('Rahul'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('a mistyped confirmation is caught before it locks an account',
|
|
(tester) async {
|
|
// There is no email to reset a PIN with. A typo nobody can verify means
|
|
// the account is simply gone until an admin resets it.
|
|
await open(tester, who: admin, show: showStaffDialog);
|
|
|
|
await tester.tap(find.text('Add staff member'));
|
|
await tester.pumpAndSettle();
|
|
|
|
await tester.enterText(find.byType(TextFormField).first, 'Meena');
|
|
await tester.enterText(find.byType(TextFormField).at(1), '7391');
|
|
await tester.enterText(find.byType(TextFormField).at(2), '7392');
|
|
|
|
await tester.tap(find.text('Add'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('The two PINs do not match'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('a too-short PIN is refused', (tester) async {
|
|
await open(tester, who: admin, show: showStaffDialog);
|
|
await tester.tap(find.text('Add staff member'));
|
|
await tester.pumpAndSettle();
|
|
|
|
await tester.enterText(find.byType(TextFormField).first, 'Meena');
|
|
await tester.enterText(find.byType(TextFormField).at(1), '73');
|
|
await tester.enterText(find.byType(TextFormField).at(2), '73');
|
|
|
|
await tester.tap(find.text('Add'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('At least four digits'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('an account still on a shipped PIN is flagged', (tester) async {
|
|
await open(
|
|
tester,
|
|
who: admin,
|
|
show: showStaffDialog,
|
|
staff: const [
|
|
admin,
|
|
StaffUser(
|
|
id: 'u9',
|
|
name: 'Newbie',
|
|
role: StaffRole.cashier,
|
|
mustChangePin: true,
|
|
),
|
|
],
|
|
);
|
|
|
|
expect(find.byIcon(Icons.warning_amber_rounded), findsOneWidget);
|
|
});
|
|
});
|
|
|
|
group('store details', () {
|
|
testWidgets('a cashier cannot change what invoices claim', (tester) async {
|
|
await open(tester, who: cashier, show: showStoreDetailsDialog);
|
|
expect(find.textContaining('Only an admin'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('a malformed GSTIN is refused', (tester) async {
|
|
// It prints on every invoice as a legal requirement, so a typo is a
|
|
// compliance problem across hundreds of bills before anyone notices.
|
|
await open(tester, who: admin, show: showStoreDetailsDialog);
|
|
|
|
await tester.enterText(find.byType(TextFormField).at(2), '99AABCU9603R1ZM');
|
|
await tester.tap(find.text('Save'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(
|
|
find.text('The first two digits are not a valid state code.'),
|
|
findsOneWidget,
|
|
);
|
|
});
|
|
|
|
testWidgets('an empty seller name is refused', (tester) async {
|
|
await open(tester, who: admin, show: showStoreDetailsDialog);
|
|
|
|
await tester.enterText(find.byType(TextFormField).first, '');
|
|
await tester.tap(find.text('Save'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('An invoice must name the seller'), findsOneWidget);
|
|
});
|
|
});
|
|
}
|
|
|
|
/// Holds a fixed session so a test can choose who is signed in.
|
|
class _StubAuth extends AuthController {
|
|
_StubAuth(StoreAccount store, StaffUser user) : super(_throwingRef) {
|
|
// The shell a session opens is decided by the back office, not by the
|
|
// person at the counter — so a stub has to state it too. Taken from the
|
|
// user's role here purely so these tests keep reading as "signed in as the
|
|
// admin" / "signed in as the cashier".
|
|
state = Authenticated(
|
|
store: store,
|
|
user: user,
|
|
login: user.role == StaffRole.cashier
|
|
? TerminalLogin.cashier
|
|
: TerminalLogin.admin,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<void> refreshStore() async {}
|
|
}
|
|
|
|
/// The stub never reaches the real container, so a Ref is never used.
|
|
final Ref _throwingRef = _UnusedRef();
|
|
|
|
class _UnusedRef implements Ref {
|
|
@override
|
|
dynamic noSuchMethod(Invocation invocation) =>
|
|
throw UnsupportedError('The stubbed AuthController does not read providers.');
|
|
}
|