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>
199 lines
6.7 KiB
Dart
199 lines
6.7 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) {
|
|
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.');
|
|
}
|