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>
157 lines
5.5 KiB
Dart
157 lines
5.5 KiB
Dart
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);
|
|
});
|
|
});
|
|
}
|