feat: make server owner of tabs
This commit is contained in:
@@ -4,7 +4,6 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:kooltap/models/bar_tab.dart';
|
||||
import 'package:kooltap/models/tab_item.dart';
|
||||
import 'package:kooltap/services/bar_tab_service.dart';
|
||||
import 'package:kooltap/services/product_service.dart';
|
||||
import 'package:kooltap/viewmodels/bar_screen_view_model.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
@@ -87,7 +86,7 @@ void main() {
|
||||
expect(viewModel.isLoading, false);
|
||||
});
|
||||
|
||||
test('propagates an insufficient-stock error from the transaction', () async {
|
||||
test('propagates an error from the transaction', () async {
|
||||
const item = TabItem(
|
||||
id: 'item-1',
|
||||
tabId: 'tab-1',
|
||||
@@ -99,11 +98,11 @@ void main() {
|
||||
|
||||
when(
|
||||
() => barTabService.adjustTabItemQuantity(tabItemId: 'item-1', delta: 1),
|
||||
).thenAnswer((_) async => throw const InsufficientStockException());
|
||||
).thenAnswer((_) async => throw const TabNotFoundException('tab-1'));
|
||||
|
||||
await expectLater(
|
||||
viewModel.changeItemQuantity(item, 1),
|
||||
throwsA(isA<InsufficientStockException>()),
|
||||
throwsA(isA<TabNotFoundException>()),
|
||||
);
|
||||
|
||||
expect(viewModel.loadError, isNull);
|
||||
|
||||
@@ -226,7 +226,7 @@ void main() {
|
||||
expect(storedProduct.stockQuantity, 99);
|
||||
});
|
||||
|
||||
test('rolls back the tab insert when stock is unavailable', () async {
|
||||
test('still sells when the stock is at zero', () async {
|
||||
final tab = await service.createTab(customerName: 'John');
|
||||
final product = createTestProduct();
|
||||
|
||||
@@ -234,13 +234,14 @@ void main() {
|
||||
..where((row) => row.id.equals(product.id)))
|
||||
.write(const ProductsCompanion(stockQuantity: Value(0)));
|
||||
|
||||
await expectLater(
|
||||
service.addProductToTab(tabId: tab.id, product: product),
|
||||
throwsA(isA<InsufficientStockException>()),
|
||||
);
|
||||
await service.addProductToTab(tabId: tab.id, product: product);
|
||||
|
||||
final updatedTab = await service.getTabById(tab.id);
|
||||
expect(updatedTab!.items, isEmpty);
|
||||
expect(updatedTab!.items.single.quantity, 1);
|
||||
final storedProduct = await (database.select(
|
||||
database.products,
|
||||
)..where((row) => row.id.equals(product.id))).getSingle();
|
||||
expect(storedProduct.stockQuantity, -1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -480,21 +481,6 @@ void main() {
|
||||
expect(afterAdjust.version, afterAdd.version + 1);
|
||||
expect(afterRename.version, afterAdjust.version + 1);
|
||||
});
|
||||
|
||||
test('a failed add leaves the version untouched', () async {
|
||||
final tab = await service.createTab(customerName: 'John');
|
||||
final product = createTestProduct();
|
||||
await (database.update(database.products)
|
||||
..where((row) => row.id.equals(product.id)))
|
||||
.write(const ProductsCompanion(stockQuantity: Value(0)));
|
||||
|
||||
await expectLater(
|
||||
service.addProductToTab(tabId: tab.id, product: product),
|
||||
throwsA(isA<InsufficientStockException>()),
|
||||
);
|
||||
|
||||
expect((await service.getTabById(tab.id))!.version, tab.version);
|
||||
});
|
||||
});
|
||||
|
||||
group('watchOpenTabs', () {
|
||||
|
||||
@@ -94,4 +94,12 @@ void main() {
|
||||
expect(await database.select(database.tabEvents).get(), isEmpty);
|
||||
expect((await database.select(database.barTabs).get()), hasLength(1));
|
||||
});
|
||||
|
||||
test('creates an empty stock adjustment outbox', () async {
|
||||
expect(
|
||||
await database.select(database.stockAdjustmentResults).get(),
|
||||
isEmpty,
|
||||
);
|
||||
expect((await database.select(database.products).get()), hasLength(1));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -223,7 +223,7 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test('throws when not enough stock', () async {
|
||||
test('goes below zero instead of refusing', () async {
|
||||
await database
|
||||
.into(database.products)
|
||||
.insert(
|
||||
@@ -237,10 +237,10 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
() => service.decreaseStock('low-stock', 5),
|
||||
throwsA(isA<InsufficientStockException>()),
|
||||
);
|
||||
await service.decreaseStock('low-stock', 5);
|
||||
|
||||
final product = await service.getProductById('low-stock');
|
||||
expect(product!.stockQuantity, -2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -296,7 +296,7 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test('does not allow concurrent decrements below zero', () async {
|
||||
test('counts every concurrent decrement', () async {
|
||||
await database
|
||||
.into(database.products)
|
||||
.insert(
|
||||
@@ -310,16 +310,13 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
Future.wait([
|
||||
service.decreaseStock('concurrent-stock', 1),
|
||||
service.decreaseStock('concurrent-stock', 1),
|
||||
]),
|
||||
throwsA(isA<InsufficientStockException>()),
|
||||
);
|
||||
await Future.wait([
|
||||
service.decreaseStock('concurrent-stock', 1),
|
||||
service.decreaseStock('concurrent-stock', 1),
|
||||
]);
|
||||
|
||||
final product = await service.getProductById('concurrent-stock');
|
||||
expect(product!.stockQuantity, 0);
|
||||
expect(product!.stockQuantity, -1);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+267
-19
@@ -4,10 +4,12 @@ import 'package:drift/drift.dart' hide isNull, isNotNull;
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:kooltap/database/app_database.dart';
|
||||
import 'package:kooltap/models/bar_tab.dart';
|
||||
import 'package:kooltap/models/payment_method.dart';
|
||||
import 'package:kooltap/models/product.dart';
|
||||
import 'package:kooltap/models/tab_event.dart';
|
||||
import 'package:kooltap/services/bar_tab_service.dart';
|
||||
import 'package:kooltap/services/product_service.dart';
|
||||
|
||||
const _cola = Product(
|
||||
id: 'prod-1',
|
||||
@@ -59,8 +61,15 @@ void main() {
|
||||
return (await service.getTabById(tabId))!.items.single.id;
|
||||
}
|
||||
|
||||
/// Opens a tab and drops its `tab_opened` event.
|
||||
Future<BarTab> openTab(String name) async {
|
||||
final tab = await service.createTab(customerName: name);
|
||||
await database.delete(database.tabEvents).go();
|
||||
return tab;
|
||||
}
|
||||
|
||||
Future<BarTabRow> tabWithTwoColas() async {
|
||||
final tab = await service.createTab(customerName: 'Jan');
|
||||
final tab = await openTab('Jan');
|
||||
await service.addProductToTab(tabId: tab.id, product: _cola);
|
||||
await service.addProductToTab(tabId: tab.id, product: _cola);
|
||||
await database.delete(database.tabEvents).go();
|
||||
@@ -71,13 +80,15 @@ void main() {
|
||||
|
||||
group('line events', () {
|
||||
test('adding a product writes line_added with the common fields', () async {
|
||||
final tab = await service.createTab(customerName: 'Jan');
|
||||
final tab = await openTab('Jan');
|
||||
await service.addProductToTab(tabId: tab.id, product: _cola);
|
||||
|
||||
final event = (await outbox()).single;
|
||||
expect(event['type'], 'line_added');
|
||||
expect(event['tabId'], tab.id);
|
||||
expect(event['customerName'], 'Jan');
|
||||
expect(event['productId'], 'prod-1');
|
||||
expect(event['lineId'], await lineIdOf(tab.id));
|
||||
expect(event['productName'], 'Cola');
|
||||
expect(event['unitPriceInCents'], 250);
|
||||
expect(event['quantity'], 1);
|
||||
@@ -89,7 +100,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('adding to an existing line keeps that line\'s price', () async {
|
||||
final tab = await service.createTab(customerName: 'Jan');
|
||||
final tab = await openTab('Jan');
|
||||
await service.addProductToTab(tabId: tab.id, product: _cola);
|
||||
await service.addProductToTab(
|
||||
tabId: tab.id,
|
||||
@@ -108,7 +119,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('changing a quantity writes the difference', () async {
|
||||
final tab = await service.createTab(customerName: 'Jan');
|
||||
final tab = await openTab('Jan');
|
||||
await service.addProductToTab(tabId: tab.id, product: _cola);
|
||||
await service.addProductToTab(tabId: tab.id, product: _cola);
|
||||
final lineId = await lineIdOf(tab.id);
|
||||
@@ -137,7 +148,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('a change that does nothing writes no event', () async {
|
||||
final tab = await service.createTab(customerName: 'Jan');
|
||||
final tab = await openTab('Jan');
|
||||
await service.addProductToTab(tabId: tab.id, product: _cola);
|
||||
final lineId = await lineIdOf(tab.id);
|
||||
await service.adjustTabItemQuantity(tabItemId: lineId, delta: -1);
|
||||
@@ -149,25 +160,38 @@ void main() {
|
||||
expect(await outbox(), isEmpty);
|
||||
});
|
||||
|
||||
test('deleting a tab writes line_removed for each line', () async {
|
||||
final tab = await tabWithTwoColas();
|
||||
test(
|
||||
'deleting a tab writes line_removed for each line, then tab_deleted',
|
||||
() async {
|
||||
final tab = await tabWithTwoColas();
|
||||
|
||||
await service.deleteTab(tab.id);
|
||||
await service.deleteTab(tab.id);
|
||||
|
||||
final event = (await outbox()).single;
|
||||
expect(event['type'], 'line_removed');
|
||||
expect(event['quantity'], 2);
|
||||
expect(event['customerName'], 'Jan');
|
||||
});
|
||||
final events = await outbox();
|
||||
expect(events.map((event) => event['type']), [
|
||||
'line_removed',
|
||||
'tab_deleted',
|
||||
]);
|
||||
expect(events.first['quantity'], 2);
|
||||
expect(events.first['customerName'], 'Jan');
|
||||
expect(events.last['tabId'], tab.id);
|
||||
},
|
||||
);
|
||||
|
||||
test('later events use the new name after a rename', () async {
|
||||
final tab = await service.createTab(customerName: 'Jan');
|
||||
final tab = await openTab('Jan');
|
||||
await service.renameTab(tabId: tab.id, customerName: 'Janneke');
|
||||
expect(await outbox(), isEmpty);
|
||||
|
||||
await service.addProductToTab(tabId: tab.id, product: _cola);
|
||||
|
||||
expect((await outbox()).single['customerName'], 'Janneke');
|
||||
final events = await outbox();
|
||||
expect(events.map((event) => event['type']), [
|
||||
'tab_renamed',
|
||||
'line_added',
|
||||
]);
|
||||
expect(events.map((event) => event['customerName']), [
|
||||
'Janneke',
|
||||
'Janneke',
|
||||
]);
|
||||
});
|
||||
|
||||
test('a failed change writes no event', () async {
|
||||
@@ -180,6 +204,29 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('tab events', () {
|
||||
test('opening a tab writes tab_opened in the same transaction', () async {
|
||||
final tab = await service.createTab(customerName: 'Jan');
|
||||
|
||||
final event = (await outbox()).single;
|
||||
expect(event['type'], 'tab_opened');
|
||||
expect(event['tabId'], tab.id);
|
||||
expect(event['customerName'], 'Jan');
|
||||
// The database keeps whole seconds
|
||||
final openedAt = DateTime.parse(event['openedAt'] as String);
|
||||
expect(openedAt.difference(tab.openedAt).inSeconds, 0);
|
||||
});
|
||||
|
||||
test('a rename of an unknown tab writes nothing', () async {
|
||||
await expectLater(
|
||||
service.renameTab(tabId: 'missing', customerName: 'X'),
|
||||
throwsA(isA<TabNotFoundException>()),
|
||||
);
|
||||
|
||||
expect(await outbox(), isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('settled events', () {
|
||||
test('settling writes one settled event and no removals', () async {
|
||||
final tab = await tabWithTwoColas();
|
||||
@@ -193,8 +240,16 @@ void main() {
|
||||
expect(event['paymentMethod'], 'payconiq');
|
||||
expect(event.containsKey('commandId'), false);
|
||||
expect(event['items'], [
|
||||
{'productName': 'Cola', 'quantity': 2, 'unitPriceInCents': 250},
|
||||
{
|
||||
'productId': 'prod-1',
|
||||
'productName': 'Cola',
|
||||
'quantity': 2,
|
||||
'unitPriceInCents': 250,
|
||||
},
|
||||
]);
|
||||
// History and the server's settlement feed share the event's id
|
||||
final closed = await service.getClosedTabs();
|
||||
expect(closed.single.id, event['id']);
|
||||
});
|
||||
|
||||
test('lines that were taken back are not settled', () async {
|
||||
@@ -211,7 +266,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('settling an empty tab writes nothing', () async {
|
||||
final tab = await service.createTab(customerName: 'Jan');
|
||||
final tab = await openTab('Jan');
|
||||
|
||||
await service.closeTab(tab.id);
|
||||
|
||||
@@ -244,6 +299,170 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('stock', () {
|
||||
Future<int> colaStock() async {
|
||||
final row = await (database.select(
|
||||
database.products,
|
||||
)..where((row) => row.id.equals(_cola.id))).getSingle();
|
||||
return row.stockQuantity;
|
||||
}
|
||||
|
||||
test('moves with every line event', () async {
|
||||
final tab = await openTab('Jan');
|
||||
for (var i = 0; i < 3; i++) {
|
||||
await service.addProductToTab(tabId: tab.id, product: _cola);
|
||||
}
|
||||
expect(await colaStock(), 97);
|
||||
|
||||
await service.adjustTabItemQuantity(
|
||||
tabItemId: await lineIdOf(tab.id),
|
||||
delta: -2,
|
||||
);
|
||||
expect(await colaStock(), 99);
|
||||
|
||||
await service.deleteTab(tab.id);
|
||||
expect(await colaStock(), 100);
|
||||
});
|
||||
|
||||
test('settling leaves the stock alone', () async {
|
||||
final tab = await tabWithTwoColas();
|
||||
|
||||
await service.closeTab(tab.id);
|
||||
|
||||
expect(await colaStock(), 98);
|
||||
});
|
||||
|
||||
test('a product without tracked stock never changes', () async {
|
||||
await (database.update(database.products)
|
||||
..where((row) => row.id.equals(_cola.id)))
|
||||
.write(const ProductsCompanion(infiniteStock: Value(true)));
|
||||
final tab = await openTab('Jan');
|
||||
|
||||
await service.addProductToTab(tabId: tab.id, product: _cola);
|
||||
await service.addProductToTab(tabId: tab.id, product: _cola);
|
||||
await service.deleteTab(tab.id);
|
||||
|
||||
expect(await colaStock(), 100);
|
||||
expect(await outbox(), hasLength(4));
|
||||
});
|
||||
|
||||
test('a line whose product is gone changes no stock', () async {
|
||||
final tab = await tabWithTwoColas();
|
||||
await (database.delete(
|
||||
database.products,
|
||||
)..where((row) => row.id.equals(_cola.id))).go();
|
||||
|
||||
await service.deleteTab(tab.id);
|
||||
|
||||
expect((await outbox()).first['type'], 'line_removed');
|
||||
expect(await service.getTabById(tab.id), isNull);
|
||||
});
|
||||
|
||||
test('the stock only changes when its event is stored', () async {
|
||||
service = DriftBarTabService(
|
||||
database: database,
|
||||
eventFactory: _FixedIdEventFactory(),
|
||||
);
|
||||
final tab = await openTab('Jan');
|
||||
await service.addProductToTab(tabId: tab.id, product: _cola);
|
||||
|
||||
// The second event reuses the id, so storing it fails
|
||||
await expectLater(
|
||||
service.addProductToTab(tabId: tab.id, product: _cola),
|
||||
throwsA(anything),
|
||||
);
|
||||
|
||||
expect(await colaStock(), 99);
|
||||
expect(await outbox(), hasLength(1));
|
||||
});
|
||||
});
|
||||
|
||||
group('stock_set events', () {
|
||||
late DriftProductService products;
|
||||
|
||||
setUp(() {
|
||||
products = DriftProductService(
|
||||
database: database,
|
||||
eventFactory: TabEventFactory(
|
||||
now: () => DateTime.utc(2026, 10, 24, 15, 3, 40),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('have a product and quantity but no tab', () {
|
||||
final event = TabEventFactory(
|
||||
now: () => DateTime.utc(2026, 10, 24, 15, 3, 40),
|
||||
).stockSet(productId: 'prod-1', quantity: 30);
|
||||
|
||||
expect(event.payload, {
|
||||
'id': event.id,
|
||||
'type': 'stock_set',
|
||||
'productId': 'prod-1',
|
||||
'quantity': 30,
|
||||
'occurredAt': '2026-10-24T15:03:40.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
test('a new product queues its starting stock', () async {
|
||||
await products.createProduct(
|
||||
name: 'Water',
|
||||
category: 'Drinks',
|
||||
stockQuantity: 24,
|
||||
lowStockThreshold: 5,
|
||||
priceInCents: 200,
|
||||
imagePath: null,
|
||||
);
|
||||
await products.createProduct(
|
||||
name: 'Tap water',
|
||||
category: 'Drinks',
|
||||
stockQuantity: 0,
|
||||
lowStockThreshold: 0,
|
||||
infiniteStock: true,
|
||||
priceInCents: 0,
|
||||
imagePath: null,
|
||||
);
|
||||
|
||||
final water = (await products.getProducts()).firstWhere(
|
||||
(product) => product.name == 'Water',
|
||||
);
|
||||
final events = await outbox();
|
||||
expect(events, hasLength(1));
|
||||
expect(events.single, containsPair('type', 'stock_set'));
|
||||
expect(events.single, containsPair('productId', water.id));
|
||||
expect(events.single, containsPair('quantity', 24));
|
||||
});
|
||||
|
||||
test('a stock or tracking change queues a count', () async {
|
||||
final cola = (await products.getProductById('prod-1'))!;
|
||||
|
||||
await products.updateProduct(cola.copyWith(name: 'Coca-Cola'));
|
||||
expect(await outbox(), isEmpty);
|
||||
|
||||
await products.updateProduct(cola.copyWith(stockQuantity: 40));
|
||||
await products.updateProduct(
|
||||
cola.copyWith(stockQuantity: 40, infiniteStock: true),
|
||||
);
|
||||
await products.updateProduct(cola.copyWith(stockQuantity: 40));
|
||||
|
||||
expect(
|
||||
[for (final event in await outbox()) event['quantity']],
|
||||
[40, null, 40],
|
||||
);
|
||||
});
|
||||
|
||||
test('are skipped when replaying the outbox onto server tabs', () async {
|
||||
final tab = await openTab('Jan');
|
||||
await products.updateProduct(
|
||||
(await products.getProductById('prod-1'))!.copyWith(stockQuantity: 5),
|
||||
);
|
||||
|
||||
await service.applyServerState([]);
|
||||
|
||||
expect(await service.getTabById(tab.id), isNull);
|
||||
expect(await outbox(), hasLength(1));
|
||||
});
|
||||
});
|
||||
|
||||
test('occurredAt is the UTC time it happened', () {
|
||||
final factory = TabEventFactory(
|
||||
now: () => DateTime.utc(2026, 10, 24, 21, 41, 3, 120),
|
||||
@@ -252,6 +471,8 @@ void main() {
|
||||
final event = factory.lineAdded(
|
||||
tabId: 'a1b2',
|
||||
customerName: 'Jan',
|
||||
lineId: 'line-1',
|
||||
productId: 'prod-1',
|
||||
productName: 'Cola',
|
||||
unitPriceInCents: 250,
|
||||
quantity: 2,
|
||||
@@ -262,6 +483,8 @@ void main() {
|
||||
() => factory.lineRemoved(
|
||||
tabId: 'a1b2',
|
||||
customerName: 'Jan',
|
||||
lineId: 'line-1',
|
||||
productId: 'prod-1',
|
||||
productName: 'Cola',
|
||||
unitPriceInCents: 250,
|
||||
quantity: 0,
|
||||
@@ -270,3 +493,28 @@ void main() {
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Gives every event the same id, so the second one can't be stored.
|
||||
class _FixedIdEventFactory extends TabEventFactory {
|
||||
@override
|
||||
TabEvent lineAdded({
|
||||
required String tabId,
|
||||
required String customerName,
|
||||
required String lineId,
|
||||
required String productId,
|
||||
required String productName,
|
||||
required int unitPriceInCents,
|
||||
required int quantity,
|
||||
}) {
|
||||
final event = super.lineAdded(
|
||||
tabId: tabId,
|
||||
customerName: customerName,
|
||||
lineId: lineId,
|
||||
productId: productId,
|
||||
productName: productName,
|
||||
unitPriceInCents: unitPriceInCents,
|
||||
quantity: quantity,
|
||||
);
|
||||
return TabEvent(id: 'fixed', payload: {...event.payload, 'id': 'fixed'});
|
||||
}
|
||||
}
|
||||
|
||||
+34
-68
@@ -249,27 +249,24 @@ void main() {
|
||||
final tabId = tabIds[random.nextInt(tabIds.length)];
|
||||
final product = random.nextBool() ? _beer : _wine;
|
||||
|
||||
try {
|
||||
switch (random.nextInt(10)) {
|
||||
case < 6:
|
||||
await tabs.addProductToTab(tabId: tabId, product: product);
|
||||
case < 8:
|
||||
final tab = (await tabs.getTabById(tabId))!;
|
||||
if (tab.items.isEmpty) continue;
|
||||
final item = tab.items[random.nextInt(tab.items.length)];
|
||||
await tabs.adjustTabItemQuantity(
|
||||
tabItemId: item.id,
|
||||
delta: random.nextInt(5) - 2,
|
||||
);
|
||||
case 8:
|
||||
await tabs.closeTab(tabId);
|
||||
default:
|
||||
await tabs.deleteTab(tabId);
|
||||
tabIds.remove(tabId);
|
||||
tabIds.add((await tabs.createTab(customerName: 'New')).id);
|
||||
}
|
||||
} on InsufficientStockException {
|
||||
// Running out is part of the scenario.
|
||||
// Running out is part of the scenario: the stock goes negative
|
||||
switch (random.nextInt(10)) {
|
||||
case < 6:
|
||||
await tabs.addProductToTab(tabId: tabId, product: product);
|
||||
case < 8:
|
||||
final tab = (await tabs.getTabById(tabId))!;
|
||||
if (tab.items.isEmpty) continue;
|
||||
final item = tab.items[random.nextInt(tab.items.length)];
|
||||
await tabs.adjustTabItemQuantity(
|
||||
tabItemId: item.id,
|
||||
delta: random.nextInt(5) - 2,
|
||||
);
|
||||
case 8:
|
||||
await tabs.closeTab(tabId);
|
||||
default:
|
||||
await tabs.deleteTab(tabId);
|
||||
tabIds.remove(tabId);
|
||||
tabIds.add((await tabs.createTab(customerName: 'New')).id);
|
||||
}
|
||||
|
||||
for (final product in [_beer, _wine]) {
|
||||
@@ -278,65 +275,48 @@ void main() {
|
||||
await quantityOnOpenTabs(product.id) +
|
||||
await soldInHistory(product.id);
|
||||
expect(accounted, product.stockQuantity, reason: 'step $step');
|
||||
expect(await stockOf(product.id), greaterThanOrEqualTo(0));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('can never sell more than is in stock', () async {
|
||||
test('keeps selling when the stock runs out', () async {
|
||||
final tab = await tabs.createTab(customerName: 'John');
|
||||
var sold = 0;
|
||||
|
||||
for (var i = 0; i < _wine.stockQuantity + 5; i++) {
|
||||
try {
|
||||
await tabs.addProductToTab(tabId: tab.id, product: _wine);
|
||||
sold++;
|
||||
} on InsufficientStockException {
|
||||
// Expected once the wine runs out.
|
||||
}
|
||||
await tabs.addProductToTab(tabId: tab.id, product: _wine);
|
||||
}
|
||||
|
||||
expect(sold, _wine.stockQuantity);
|
||||
expect(await stockOf('wine'), 0);
|
||||
expect((await tabs.getTabById(tab.id))!.totalInCents, 20 * 450);
|
||||
expect(await stockOf('wine'), -5);
|
||||
expect((await tabs.getTabById(tab.id))!.totalInCents, 25 * 450);
|
||||
});
|
||||
|
||||
test('simultaneous orders for the last bottle sell it once', () async {
|
||||
test('simultaneous orders for the last bottle both go through', () async {
|
||||
await products.updateProduct(_wine.copyWith(stockQuantity: 1));
|
||||
final first = await tabs.createTab(customerName: 'A');
|
||||
final second = await tabs.createTab(customerName: 'B');
|
||||
|
||||
final results = await Future.wait([
|
||||
await Future.wait([
|
||||
for (final tab in [first, second])
|
||||
tabs
|
||||
.addProductToTab(tabId: tab.id, product: _wine)
|
||||
.then((_) => true)
|
||||
.catchError(
|
||||
(_) => false,
|
||||
test: (e) => e is InsufficientStockException,
|
||||
),
|
||||
tabs.addProductToTab(tabId: tab.id, product: _wine),
|
||||
]);
|
||||
|
||||
expect(results.where((ok) => ok), hasLength(1));
|
||||
expect(await stockOf('wine'), 0);
|
||||
expect(await quantityOnOpenTabs('wine'), 1);
|
||||
expect(await stockOf('wine'), -1);
|
||||
expect(await quantityOnOpenTabs('wine'), 2);
|
||||
});
|
||||
|
||||
test('raising a quantity past the stock adds nothing', () async {
|
||||
test('raising a quantity past the stock goes below zero', () async {
|
||||
await products.updateProduct(_wine.copyWith(stockQuantity: 2));
|
||||
final tab = await tabs.createTab(customerName: 'John');
|
||||
await tabs.addProductToTab(tabId: tab.id, product: _wine);
|
||||
|
||||
await expectLater(
|
||||
tabs.adjustTabItemQuantity(
|
||||
tabItemId: await itemId(tab.id, 'wine'),
|
||||
delta: 5,
|
||||
),
|
||||
throwsA(isA<InsufficientStockException>()),
|
||||
final applied = await tabs.adjustTabItemQuantity(
|
||||
tabItemId: await itemId(tab.id, 'wine'),
|
||||
delta: 5,
|
||||
);
|
||||
|
||||
expect(await stockOf('wine'), 1);
|
||||
expect((await tabs.getTabById(tab.id))!.items.single.quantity, 1);
|
||||
expect(applied, 5);
|
||||
expect(await stockOf('wine'), -4);
|
||||
expect((await tabs.getTabById(tab.id))!.items.single.quantity, 6);
|
||||
});
|
||||
|
||||
test('lowering past zero only returns what was on the tab', () async {
|
||||
@@ -372,19 +352,5 @@ void main() {
|
||||
|
||||
expect(await stockOf('water'), 0);
|
||||
});
|
||||
|
||||
test('a failed add leaves no item behind', () async {
|
||||
await products.updateProduct(_wine.copyWith(stockQuantity: 0));
|
||||
final tab = await tabs.createTab(customerName: 'John');
|
||||
|
||||
await expectLater(
|
||||
tabs.addProductToTab(tabId: tab.id, product: _wine),
|
||||
throwsA(isA<InsufficientStockException>()),
|
||||
);
|
||||
|
||||
final open = (await tabs.getTabById(tab.id))!;
|
||||
expect(open.items, isEmpty);
|
||||
expect(open.totalInCents, 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -59,7 +59,6 @@ void main() {
|
||||
tabs: tabs,
|
||||
selectedTab: selectedTab,
|
||||
selectedTabId: selectedTab?.id,
|
||||
stockByProductId: const {},
|
||||
showSearch: showSearch,
|
||||
onTabSelected: (_) {},
|
||||
onItemQuantityChanged: (_, _) async {},
|
||||
|
||||
Reference in New Issue
Block a user