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/app.dart'; import 'package:nearle_pos/data/datasources/local_store.dart'; import 'package:nearle_pos/data/datasources/seed_data.dart'; import 'package:nearle_pos/data/remote/pos_auth_api.dart'; import 'package:nearle_pos/domain/entities/pos_session.dart'; import 'package:nearle_pos/app/providers.dart'; import 'package:nearle_pos/domain/entities/shift_report.dart'; import 'package:nearle_pos/domain/entities/store_account.dart'; import 'package:nearle_pos/presentation/pos/providers/cart_controller.dart'; import 'package:nearle_pos/presentation/pos/screens/pos_dashboard_screen.dart'; import 'package:nearle_pos/presentation/sync/providers/sync_controller.dart'; /// Boots the real application widget. /// /// The unit suite proves the money is right; this proves the thing actually /// assembles — router guard, provider graph, theme and shell — which type /// checking alone cannot tell you. void main() { setUpAll(() { // Tests have no network, and an attempted font fetch throws. GoogleFonts.config.allowRuntimeFetching = false; LocalStore.registerSeed( products: SeedData.products, customers: SeedData.customers, ); }); setUp(() async { await LocalStore.instance.reset(withCatalogue: true); }); const testStore = StoreAccount( id: 'store-001', name: 'Nearle Daily', email: 'manager@ragulstores.test', address: '1 Test Street', gstin: '33AABCU9603R1ZM', phone: '9840000000', staff: [ StaffUser(id: 'u1', name: 'Suriya', role: StaffRole.admin), ], ); ShiftReport blankReport() => ShiftReport.blank( businessDate: DateTime(2026, 7, 31), terminalId: 'TERM-01', cashierName: 'Suriya', ); Future bootApp(WidgetTester tester, {bool supervisor = true}) async { await tester.pumpWidget( ProviderScope( overrides: [ // The background drain would open a broker connection and hit the // disk on a clock this test controls. Neither is what these tests // measure, and a half-driven timer would leak into the next one. syncBootstrapProvider.overrideWith((ref) async {}), // Sign-in now reads staff and store details from SQLite. Real disk // I/O cannot complete inside a fixed number of pumps on a fake // clock, so the sign-in would hang and every later assertion would // fail on a screen that never arrived. storeAccountProvider.overrideWith((ref) async => testStore), // Sign-in is a network call now — a person's own back-office account // rather than two constants compiled into the build. A widget test // must not depend on a live endpoint, so the client is swapped for // one that answers with a fixed session. posAuthApiProvider.overrideWithValue( _FakePosAuthApi(canManageStaff: supervisor), ), // Catalogue reads come from the in-memory cache and resolve on the // spot, but these four go to SQLite. Real disk I/O cannot be driven // by the fake clock a widget test runs on: sqflite's own lock-warning // timer is left pending and trips the binding's leak check. Stubbed // so this test measures rendering, which is what it is for. unsyncedCountProvider.overrideWith((ref) async => 0), promosProvider.overrideWith((ref) async => []), activePromosProvider.overrideWith((ref) async => []), parkedBillsProvider.overrideWith((ref) async => []), orderSyncRowsProvider.overrideWith((ref) async => []), todayReportProvider.overrideWith((ref) async => blankReport()), myShiftReportProvider.overrideWith((ref) async => blankReport()), ], child: const NearlePosApp(), ), ); await tester.pumpAndSettle(); } /// Pumps a fixed number of frames instead of settling. /// /// Once past login the header subscribes to a periodic clock, so there is /// always another frame pending and `pumpAndSettle` never returns. Future settle(WidgetTester tester, {int frames = 15}) async { for (var i = 0; i < frames; i++) { await tester.pump(const Duration(milliseconds: 100)); } } Future signIn(WidgetTester tester) async { final fields = find.byType(TextFormField); await tester.enterText(fields.first, _testEmail); await tester.enterText(fields.at(1), _testPassword); await tester.pump(); // Sign-in reaches SQLite now: it writes the outlet the back office named // and the store details a receipt is legally required to carry, before the // shell opens. Real disk I/O cannot complete on a widget test's fake clock, // so the tap runs inside runAsync — pumping alone leaves the sign-in // suspended for ever and every later assertion fails on a screen that never // arrived. await tester.runAsync(() async { await tester.tap(find.text('Sign in').last); await Future.delayed(const Duration(milliseconds: 200)); }); await settle(tester); } testWidgets('the terminal starts on the login screen', (tester) async { await bootApp(tester); expect(find.byType(TextFormField), findsWidgets); expect(find.text('Sign in'), findsWidgets); expect(tester.takeException(), isNull); }); testWidgets('the auth guard keeps an unauthenticated terminal out', (tester) async { await bootApp(tester); expect(find.byType(PosDashboardScreen), findsNothing); }); testWidgets('signing in reaches the billing terminal', (tester) async { await bootApp(tester); await signIn(tester); expect(find.byType(PosDashboardScreen), findsOneWidget); expect(tester.takeException(), isNull); }); testWidgets('the shell renders every module without throwing', (tester) async { // Wide enough that the sidebar shows labels and the bill stays docked — // the layout where a button label and its total compete for width. tester.view.physicalSize = const Size(1800, 1200); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); await bootApp(tester); await signIn(tester); for (final label in [ 'Customers', 'Product Import', 'Promo', 'Events', 'Settings', 'POS', ]) { final target = find.text(label); expect(target, findsWidgets, reason: 'no sidebar entry for "$label"'); await tester.tap(target.first); await settle(tester); // Settings builds a printer-settings controller that reads six values // out of SQLite, and sqflite arms a ten-second lock-warning timer around // each. Those reads cannot complete on a fake clock, so the timer would // still be pending at teardown and the binding would fail the test for // that rather than for anything it is about. Pumping past the ten // seconds lets the timer fire and clear. await tester.pump(const Duration(seconds: 11)); expect(tester.takeException(), isNull, reason: 'opening "$label" threw'); } }); testWidgets('a cashier session gets billing and nothing else', (tester) async { // The other half of the role split, and the half worth pinning: the shell // a person gets is decided by the back office, not by which tab they // picked on the way in. Same credentials, same screen size, same boot — // the only difference is `can_manage_staff` on the session, and the // back-office modules have to be unreachable because of it. tester.view.physicalSize = const Size(1800, 1200); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); await bootApp(tester, supervisor: false); await signIn(tester); // Signed in, and on the billing screen. expect(find.byType(PosDashboardScreen), findsOneWidget); // These labels exist only in the sidebar, so their absence is the whole // claim: a cashier cannot reach the catalogue, the promos or the // terminal's configuration. for (final label in ['Product Import', 'Promo', 'Settings']) { expect( find.text(label), findsNothing, reason: 'a cashier must not be offered "$label"', ); } expect(tester.takeException(), isNull); }); testWidgets('the back office connection dialog opens and validates', (tester) async { // The only way a shop can point a till at a broker. Until it existed a // store was wired up by editing a provider and rebuilding. tester.view.physicalSize = const Size(1800, 1200); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); await bootApp(tester); await signIn(tester); await tester.tap(find.text('Settings').first); await settle(tester); // Clears sqflite's lock-warning timer — see the module loop above. await tester.pump(const Duration(seconds: 11)); await tester.tap(find.text('Configure').first); await settle(tester); // "Back office connection" is both the dialog title and the About card's // button, so match the dialog itself. expect(find.byType(AlertDialog), findsOneWidget); expect(find.text('MQTT'), findsOneWidget); // Switching to MQTT and saving with no host must be refused, not silently // accepted — a terminal pointed at nothing looks identical to one that is // simply offline. await tester.tap(find.text('MQTT')); await settle(tester); await tester.tap(find.text('Save')); await settle(tester); expect(find.text('A broker host is required'), findsOneWidget); expect(find.byType(AlertDialog), findsOneWidget, reason: 'the dialog must stay open on a validation failure',); expect(tester.takeException(), isNull); }); } const _testEmail = 'manager@ragulstores.test'; const _testPassword = 'correct-horse'; /// A back office that accepts one account and refuses everything else. /// /// Subclasses rather than reimplements an interface because the real client is /// concrete — and answering a wrong password correctly matters here: the login /// screen's failure path is part of what these tests cover. class _FakePosAuthApi extends PosAuthApi { _FakePosAuthApi({this.canManageStaff = true}) : super(baseUrl: 'https://example.invalid/pos'); /// Which shell the back office says this account gets. A supervisor by /// default, because most of these tests are about the full shell rendering. final bool canManageStaff; @override Future login({ required String authname, required String password, String? terminalId, String? deviceId, int? locationId, int? configId, }) async { if (authname.trim() != _testEmail || password != _testPassword) { throw const PosAuthException( 'those sign-in details were not recognised', isCredentialFailure: true, ); } return PosSession( token: 'test-session-token', expiresAt: DateTime.now().add(const Duration(days: 30)), userId: 1229, fullName: 'Test Manager', email: _testEmail, roleId: canManageStaff ? 7 : 8, role: canManageStaff ? 'Supervisor' : 'Cashier', canManageStaff: canManageStaff, tenantId: 1087, tenantName: 'Ragul Stores', storeId: '1135', locationId: 1135, locationName: 'Ragul stores Selvapuram', outlets: const [ PosOutlet(locationId: 1135, locationName: 'Ragul stores Selvapuram'), ], ); } @override void dispose() {} }