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:
Suriya
2026-08-01 13:18:56 +05:30
parent 3513281a11
commit fdd90f28d9
12 changed files with 575 additions and 36 deletions

View 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);
});
});
}