Three changes, all driven by what the back office turned out to need.
The broker is shared with the rider fleet on nearle/riders/…, so topics
move under nearle/pos/{locationid}/{terminal}/… — one ACL rule per
system, and it is obvious from a topic which one owns it. Store ID now
carries the back office's numeric location id; the tenant is resolved
from it server-side and never taken from the wire.
A till publishes a heartbeat every 30 seconds on its own topic. The Last
Will already answers "is it dead", which is not enough to run a hundred
shops on: the failure that costs money is a terminal that is connected,
selling, and quietly holding two hundred bills it has never uploaded. So
the beat carries queue depth, the age of the oldest thing waiting,
today's trading, and printer reachability. Not retained — the back
office holds it under a TTL, and a retained beat would leave an
unplugged till looking alive until something overwrote it.
Bills now carry tax_breakdown, the GST slab split the cart already
computes. A tax return is filed per slab, and recomputing the split
server-side would mean redoing the discount apportionment and getting
exactly the same answer — or else the filed figure stops matching the
paper the shopper was handed.
Docs rewritten against the real deployment: Eclipse Mosquitto 2.1.2, no
NATS anywhere reachable, no TLS, and a broker whose queue and autosave
defaults mean it must not be treated as durable storage.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
176 lines
6.6 KiB
Dart
176 lines
6.6 KiB
Dart
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:nearle_pos/core/config/sync_config.dart';
|
|
import 'package:nearle_pos/core/utils/formatters.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/app_database.dart';
|
|
import 'package:nearle_pos/data/local/terminal_identity.dart';
|
|
import 'package:nearle_pos/data/repositories/customer_repository_impl.dart';
|
|
import 'package:nearle_pos/data/repositories/product_repository_impl.dart';
|
|
import 'package:nearle_pos/data/repositories/transaction_repository_impl.dart';
|
|
import 'package:nearle_pos/domain/entities/cart.dart';
|
|
import 'package:nearle_pos/domain/entities/transaction.dart';
|
|
import 'package:nearle_pos/domain/usecases/checkout_sale.dart';
|
|
|
|
/// What has to hold when the same build is installed on 100 tills.
|
|
///
|
|
/// Every one of these was broken: the terminal id was the literal `TERM-01` in
|
|
/// five places, so the whole fleet shared one identity, one set of MQTT topics
|
|
/// and one invoice series.
|
|
void main() {
|
|
late LocalStore store;
|
|
|
|
setUpAll(() {
|
|
LocalStore.registerSeed(
|
|
products: SeedData.products,
|
|
customers: SeedData.customers,
|
|
);
|
|
});
|
|
|
|
setUp(() async {
|
|
store = LocalStore.instance;
|
|
await store.reset(withCatalogue: true);
|
|
});
|
|
|
|
group('identity', () {
|
|
test('a device mints an identity on first run and keeps it', () async {
|
|
final identityStore = TerminalIdentityStore(store.catalogue);
|
|
|
|
final first = await identityStore.load();
|
|
expect(first.deviceId, isNotEmpty);
|
|
expect(first.code, startsWith('T'));
|
|
|
|
// A second read must not re-mint. If it did, a terminal would change
|
|
// identity on every restart and orphan the bills it had already written.
|
|
final second = await identityStore.load();
|
|
expect(second.deviceId, first.deviceId);
|
|
expect(second.code, first.code);
|
|
});
|
|
|
|
test('two devices get different codes', () {
|
|
// Derived from the device UUID rather than a counter, because there is no
|
|
// shared counter to draw from — each till mints alone and offline.
|
|
final a = TerminalIdentityStore.codeFor(
|
|
'a1b2c3d4-0000-0000-0000-000000000000',
|
|
);
|
|
final b = TerminalIdentityStore.codeFor(
|
|
'9f8e7d6c-0000-0000-0000-000000000000',
|
|
);
|
|
expect(a, 'TA1B2');
|
|
expect(b, 'T9F8E');
|
|
expect(a, isNot(b));
|
|
});
|
|
|
|
test('renaming keeps the device id, so history still points at the till',
|
|
() async {
|
|
final identityStore = TerminalIdentityStore(store.catalogue);
|
|
final before = await identityStore.load();
|
|
|
|
await identityStore.rename(code: 'till7', name: 'Counter 7');
|
|
final after = await identityStore.load();
|
|
|
|
expect(after.code, 'TILL7');
|
|
expect(after.name, 'Counter 7');
|
|
expect(after.deviceId, before.deviceId,
|
|
reason: 'the machine identity must survive a re-code',);
|
|
});
|
|
|
|
test('the identity is loaded by the store before anything is written',
|
|
() async {
|
|
expect(store.isReady, isTrue);
|
|
expect(store.terminal.deviceId, isNotEmpty);
|
|
expect(store.terminal.code, startsWith('T'));
|
|
});
|
|
});
|
|
|
|
group('invoice numbers', () {
|
|
test('two terminals ringing their first sale do not collide', () {
|
|
// The counter lives in each till's own database, so without the terminal
|
|
// code every device in the fleet mints INV-2608-00001 for its first sale.
|
|
final date = DateTime(2026, 8, 1);
|
|
final onTillA = Formatters.invoiceNumber(1, date, terminalCode: 'TA1B2');
|
|
final onTillB = Formatters.invoiceNumber(1, date, terminalCode: 'T9F8E');
|
|
|
|
expect(onTillA, 'INV-2608-TA1B2-00001');
|
|
expect(onTillA, isNot(onTillB));
|
|
});
|
|
|
|
test('a real sale carries this terminal\'s code', () async {
|
|
final products = ProductRepositoryImpl(store);
|
|
final checkout = CheckoutSale(
|
|
productRepository: products,
|
|
customerRepository: CustomerRepositoryImpl(store),
|
|
transactionRepository: TransactionRepositoryImpl(store),
|
|
);
|
|
|
|
final milk = (await products.findByBarcode('8901234500011'))!;
|
|
final result = await checkout(
|
|
cart: Cart(lines: [CartLine(product: milk, quantity: 1)]),
|
|
payments: const [
|
|
PaymentSplit(method: PaymentMethod.cash, amount: 62, tendered: 100),
|
|
],
|
|
cashierName: 'Divya',
|
|
terminalId: store.terminal.code,
|
|
);
|
|
|
|
expect(result.transaction.invoiceNumber, contains(store.terminal.code));
|
|
expect(result.transaction.terminalId, store.terminal.code);
|
|
});
|
|
});
|
|
|
|
group('topics', () {
|
|
test('two terminals in one store publish on different topics', () {
|
|
const a = SyncConfig(storeId: 'store-01', terminalId: 'TA1B2');
|
|
const b = SyncConfig(storeId: 'store-01', terminalId: 'T9F8E');
|
|
|
|
expect(a.orderTopic, isNot(b.orderTopic));
|
|
expect(a.statusTopic, isNot(b.statusTopic));
|
|
// A shared client id would evict the other till from the broker on every
|
|
// connect, in a loop.
|
|
expect(a.clientId, isNot(b.clientId));
|
|
});
|
|
|
|
test('the catalogue topic is shared, because a price change is store-wide',
|
|
() {
|
|
const a = SyncConfig(storeId: 'store-01', terminalId: 'TA1B2');
|
|
const b = SyncConfig(storeId: 'store-01', terminalId: 'T9F8E');
|
|
expect(a.catalogueTopic, b.catalogueTopic);
|
|
});
|
|
|
|
test('topics translate to NATS subjects', () {
|
|
// NATS' MQTT gateway maps / to . — this is what a JetStream consumer
|
|
// binds to.
|
|
const config = SyncConfig(storeId: 'store-01', terminalId: 'TA1B2');
|
|
expect(
|
|
SyncConfig.asNatsSubject(config.orderTopic),
|
|
'nearle.pos.store-01.TA1B2.order',
|
|
);
|
|
expect(
|
|
SyncConfig.asNatsSubject(config.statusTopic),
|
|
'nearle.pos.store-01.TA1B2.status',
|
|
);
|
|
expect(
|
|
SyncConfig.asNatsSubject(config.healthTopic),
|
|
'nearle.pos.store-01.TA1B2.health',
|
|
);
|
|
});
|
|
});
|
|
|
|
group('database pragmas', () {
|
|
test('foreign keys are enforced', () async {
|
|
final rows =
|
|
await AppDatabase.instance.db.rawQuery('PRAGMA foreign_keys');
|
|
expect(rows.first.values.first, 1,
|
|
reason: 'order_items must cascade with their order',);
|
|
});
|
|
|
|
test('a contended lock waits instead of throwing', () async {
|
|
// Default is 0, which surfaces at checkout as "database is locked" —
|
|
// a failed sale with a customer standing there.
|
|
final rows =
|
|
await AppDatabase.instance.db.rawQuery('PRAGMA busy_timeout');
|
|
expect(rows.first.values.first, greaterThanOrEqualTo(5000));
|
|
});
|
|
});
|
|
}
|