Build staff management, store details editing, and a forced PIN change

Wires the last two dead buttons in Settings and closes the loop on the
credential work: hashed PINs are only worth having if a shop can actually
change them.

Users & roles (Manage)
- Add, rename, re-role and remove staff. Admin-only at the door, because
  anyone who can edit staff can make themselves an admin.
- PIN and confirmation are both required and must match. There is no email to
  reset a PIN with, so a typo nobody can verify locks the account out until an
  admin intervenes.
- Editing someone leaves their PIN alone unless a new one is typed. An admin
  setting another person's PIN counts as a reset and re-arms must-change.
- Removal is a deactivation with a confirmation that explains why: bills
  already rung keep the cashier's name, so shift reports stay correct.
- Anyone still on a shipped PIN is flagged in the list and in Settings.

Store details (Edit)
- Name, address, GSTIN and phone now editable and persisted. GSTIN is format
  and state-code validated; it prints on every invoice as a legal requirement,
  so a typo is a compliance problem across hundreds of bills.
- Admin-only: changing the GSTIN changes what every future invoice claims
  about who collected the tax.

Forced PIN change
- Shown once after sign-in while must-change is set, and not dismissable. The
  seeded PINs are in the source of the build, so a terminal still running one
  is effectively unprotected.

Fixed while testing: the role dropdown laid its items out at natural width and
"Manager — Sales, inventory and reports" overflowed the dialog by 222px. Now
isExpanded with the description spelled out below, where it is readable.

Tests: 160 -> 168. Covers both role guards, the mismatched and too-short PIN
paths, the default-PIN flag, and GSTIN and seller-name validation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Suriya
2026-08-01 13:10:49 +05:30
parent e17937e8f1
commit 3513281a11
5 changed files with 950 additions and 6 deletions

View File

@@ -0,0 +1,198 @@
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) {
state = Authenticated(store: store, user: user);
}
@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.');
}