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>
310 lines
12 KiB
Dart
310 lines
12 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/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<void> 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<void> settle(WidgetTester tester, {int frames = 15}) async {
|
|
for (var i = 0; i < frames; i++) {
|
|
await tester.pump(const Duration(milliseconds: 100));
|
|
}
|
|
}
|
|
|
|
Future<void> 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<void>.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<PosSession> 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() {}
|
|
}
|