Open the cash drawer for real, and persist the back-office route
Cash drawer - openCashDrawer was a debugPrint. The drawer never opened. - It cannot go through the PDF pipeline: a PDF is rendered by the platform driver, which will not pass raw ESC/POS bytes to the device. So it goes over a socket instead — nearly every network thermal printer listens on 9100 and forwards whatever arrives straight to the print head, which makes the whole protocol five bytes. - Printer IP and port are configurable in Settings with a Test button that saves and fires immediately, because a drawer that does not open is indistinguishable from one that is not wired up. - Every failure explains itself: unreachable, refused, or simply not configured — which is the honest state for a USB printer, since there is no raw path to one from Flutter. - Now fires only on a cash tender. A card-only sale that pops the drawer is a shrinkage risk, and it is the first thing a shop notices. Back-office route - Host, port, TLS and transport persist to the database; username, password and API key go to the platform keystore (Keychain / Credential Manager / Android Keystore). Writing credentials into SQLite would put them in the same file as the bills, on a machine behind a shop counter. - Loaded at startup. Previously the dialog wrote settings that were silently ignored on the next launch, which reads exactly like they never saved — and credentials retyped every morning end up on a sticky note instead. - A saved route never overwrites the terminal's store or terminal id. Those belong to the device, and re-pointing a till at a different broker must not change who it is, or its bills and presence records stop lining up. Tests: 168 -> 176. The drawer test stands up a real socket server and asserts the exact bytes arrive. The config test asserts no credential appears anywhere in the meta table while the non-secret settings do. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
156
test/unit/hardware_config_test.dart
Normal file
156
test/unit/hardware_config_test.dart
Normal file
@@ -0,0 +1,156 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nearle_pos/core/config/sync_config.dart';
|
||||
import 'package:nearle_pos/core/services/cash_drawer_service.dart';
|
||||
import 'package:nearle_pos/data/datasources/local_store.dart';
|
||||
import 'package:nearle_pos/data/datasources/seed_data.dart';
|
||||
import 'package:nearle_pos/data/local/sync_config_store.dart';
|
||||
|
||||
void main() {
|
||||
setUpAll(() {
|
||||
LocalStore.registerSeed(
|
||||
products: SeedData.products,
|
||||
customers: SeedData.customers,
|
||||
);
|
||||
});
|
||||
|
||||
group('cash drawer', () {
|
||||
test('sends the ESC/POS kick to the printer', () async {
|
||||
// Previously a debugPrint, so the drawer never opened. The PDF pipeline
|
||||
// cannot carry these bytes — the platform driver renders a document, it
|
||||
// does not pass raw commands through — so this goes over a socket.
|
||||
final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
|
||||
final received = Completer<List<int>>();
|
||||
|
||||
server.listen((socket) {
|
||||
socket.listen((bytes) {
|
||||
if (!received.isCompleted) received.complete(bytes);
|
||||
});
|
||||
});
|
||||
|
||||
final result = await CashDrawerService()
|
||||
.open(host: server.address.address, port: server.port);
|
||||
|
||||
expect(result, DrawerResult.opened);
|
||||
expect(await received.future, CashDrawerService.kickCommand);
|
||||
// ESC p 0 25 250 — pin 2, the near-universal wiring.
|
||||
expect(CashDrawerService.kickCommand, [27, 112, 0, 25, 250]);
|
||||
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test('a blank address is not an error', () async {
|
||||
// Plenty of shops take card only, or open the drawer by hand. A USB
|
||||
// printer also has no raw path from Flutter, so this is the honest state
|
||||
// rather than a failure to report.
|
||||
expect(await CashDrawerService().open(), DrawerResult.notConfigured);
|
||||
expect(
|
||||
await CashDrawerService().open(host: ' '),
|
||||
DrawerResult.notConfigured,
|
||||
);
|
||||
});
|
||||
|
||||
test('an unreachable printer reports it instead of hanging the sale',
|
||||
() async {
|
||||
// Port 1 on loopback refuses immediately.
|
||||
final result =
|
||||
await CashDrawerService().open(host: '127.0.0.1', port: 1);
|
||||
|
||||
expect(result, DrawerResult.unreachable);
|
||||
expect(result.isSuccess, isFalse);
|
||||
expect(result.message, contains('Could not reach'));
|
||||
});
|
||||
|
||||
test('every outcome explains itself to the cashier', () {
|
||||
for (final result in DrawerResult.values) {
|
||||
expect(result.message, isNotEmpty);
|
||||
}
|
||||
expect(DrawerResult.opened.isSuccess, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('sync configuration', () {
|
||||
late LocalStore store;
|
||||
|
||||
setUp(() async {
|
||||
store = LocalStore.instance;
|
||||
await store.reset(withCatalogue: true);
|
||||
});
|
||||
|
||||
test('the route survives a restart', () async {
|
||||
// Held only in memory these had to be retyped after every restart, which
|
||||
// on a shop-floor terminal means they end up on a sticky note instead.
|
||||
const configured = SyncConfig(
|
||||
transport: TransportKind.mqtt,
|
||||
brokerHost: 'nats.example.com',
|
||||
brokerPort: 1883,
|
||||
useTls: false,
|
||||
storeId: 'store-77',
|
||||
terminalId: 'T9A1',
|
||||
);
|
||||
|
||||
await store.syncConfig.save(configured);
|
||||
|
||||
// A fresh store object, as if the app had been relaunched.
|
||||
final reloaded = await SyncConfigStore(store.catalogue)
|
||||
.load(const SyncConfig(storeId: 'store-77', terminalId: 'T9A1'));
|
||||
|
||||
expect(reloaded.transport, TransportKind.mqtt);
|
||||
expect(reloaded.brokerHost, 'nats.example.com');
|
||||
expect(reloaded.brokerPort, 1883);
|
||||
expect(reloaded.useTls, isFalse);
|
||||
});
|
||||
|
||||
test('no credential is written to the database', () async {
|
||||
// The whole reason they go to the platform keystore. SQLite holds the
|
||||
// bills, on a machine behind a shop counter.
|
||||
await store.syncConfig.save(const SyncConfig(
|
||||
transport: TransportKind.mqtt,
|
||||
brokerHost: 'nats.example.com',
|
||||
username: 'till-04',
|
||||
password: 'sup3rs3cret',
|
||||
apiKey: 'ak_live_9f21',
|
||||
),);
|
||||
|
||||
final rows = await store.catalogue.allMeta();
|
||||
final everything = rows.entries.map((e) => '${e.key}=${e.value}').join('|');
|
||||
|
||||
expect(everything, isNot(contains('sup3rs3cret')));
|
||||
expect(everything, isNot(contains('ak_live_9f21')));
|
||||
// Non-secret settings are expected to be there — that is the point of
|
||||
// the split.
|
||||
expect(everything, contains('nats.example.com'));
|
||||
});
|
||||
|
||||
test('the terminal identity is never overwritten by a saved route',
|
||||
() async {
|
||||
// Store and terminal ids belong to the device. Re-pointing a till at a
|
||||
// different broker must not change who it is, or its bills and its
|
||||
// presence records stop lining up.
|
||||
await store.syncConfig.save(const SyncConfig(
|
||||
transport: TransportKind.http,
|
||||
httpBaseUrl: 'https://api.example.com',
|
||||
storeId: 'wrong-store',
|
||||
terminalId: 'WRONG',
|
||||
),);
|
||||
|
||||
final reloaded = await SyncConfigStore(store.catalogue).load(
|
||||
const SyncConfig(storeId: 'store-01', terminalId: 'T4A9'),
|
||||
);
|
||||
|
||||
expect(reloaded.storeId, 'store-01');
|
||||
expect(reloaded.terminalId, 'T4A9');
|
||||
expect(reloaded.httpBaseUrl, 'https://api.example.com');
|
||||
});
|
||||
|
||||
test('an unconfigured terminal falls back rather than failing', () async {
|
||||
final loaded = await SyncConfigStore(store.catalogue)
|
||||
.load(const SyncConfig());
|
||||
|
||||
expect(loaded.transport, TransportKind.simulated);
|
||||
expect(loaded.isConfigured, isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user