@stamhoofd/backend 2.138.2 → 2.140.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +17 -17
- package/src/crons/fake-settlements.test.ts +165 -0
- package/src/crons/fake-settlements.ts +117 -0
- package/src/crons/index.ts +1 -0
- package/src/endpoints/auth/CreateTokenEndpoint.ts +5 -2
- package/src/endpoints/auth/ForgotPasswordEndpoint.test.ts +45 -0
- package/src/endpoints/auth/ForgotPasswordEndpoint.ts +2 -23
- package/src/endpoints/auth/MFA.security.test.ts +76 -1
- package/src/endpoints/auth/MFA.test.ts +187 -3
- package/src/endpoints/auth/VerifyEmailEndpoint.ts +15 -1
- package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemBreakdownEndpoint.test.ts +543 -0
- package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemBreakdownEndpoint.ts +78 -0
- package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemEndpoint.ts +3 -1
- package/src/endpoints/organization/dashboard/payments/GetPaymentBreakdownEndpoint.test.ts +1095 -0
- package/src/endpoints/organization/dashboard/payments/GetPaymentBreakdownEndpoint.ts +111 -0
- package/src/endpoints/organization/dashboard/payments/GetPaymentsEndpoint.ts +3 -79
- package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +1 -1
- package/src/endpoints/organization/shared/GetPaymentEndpoint.ts +3 -1
- package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +1 -1
- package/src/excel-loaders/balance-item-payments.ts +145 -0
- package/src/excel-loaders/index.ts +1 -0
- package/src/excel-loaders/payments.ts +45 -37
- package/src/helpers/StripeInvoicer.ts +1 -1
- package/src/helpers/TwoFactorHelper.ts +91 -9
- package/src/helpers/breakdownRelations.ts +48 -0
- package/src/helpers/getNextPageRequest.ts +24 -0
- package/src/helpers/streamForBreakdown.test.ts +107 -0
- package/src/helpers/streamForBreakdown.ts +104 -0
- package/src/services/DocumentRenderService.test.ts +72 -1
- package/src/services/PasswordForgotService.ts +31 -1
- package/src/services/PaymentService.ts +9 -1
- package/src/services/SSOService.ts +14 -1
- package/src/sql-filters/balance-item-payments-root.ts +48 -0
- package/src/sql-filters/balance-items.ts +55 -0
- package/src/sql-filters/orders.ts +6 -2
- package/src/sql-filters/payment-settlement.test.ts +68 -0
- package/src/sql-filters/payment-settlement.ts +43 -0
- package/src/sql-filters/payments.ts +93 -31
- package/src/sql-sorters/balance-item-payments.ts +32 -0
- package/tests/filters/orders.test.ts +635 -0
- package/tests/helpers/ExportSlice.ts +71 -0
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
import { Database } from '@simonbackx/simple-database';
|
|
2
|
+
import type { Organization, Webshop } from '@stamhoofd/models';
|
|
3
|
+
import { BalanceItem, BalanceItemPayment, Order, OrganizationFactory, Payment, WebshopFactory } from '@stamhoofd/models';
|
|
4
|
+
import { compileToSQLFilter, SQL } from '@stamhoofd/sql';
|
|
5
|
+
import type { StamhoofdFilter } from '@stamhoofd/structures';
|
|
6
|
+
import { Address, BalanceItemType, Cart, CartItem, CartItemPrice, CheckoutMethodType, compileToInMemoryFilter, Customer, DiscountCode, OrderData, OrderStatus, PaymentMethod, PaymentStatus, privateOrderWithTicketsFilterCompilers, Product, ProductPrice, RecordCheckboxAnswer, RecordChoice, RecordChooseOneAnswer, RecordDateAnswer, RecordIntegerAnswer, RecordMultipleChoiceAnswer, RecordSettings, RecordTextAnswer, RecordType, WebshopTakeoutMethod, WebshopTimeSlot } from '@stamhoofd/structures';
|
|
7
|
+
import { Country } from '@stamhoofd/types/Country';
|
|
8
|
+
|
|
9
|
+
import { orderFilterCompilers } from '../../src/sql-filters/orders.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* These tests pin the in-memory order filters (used in the dashboard on locally cached orders) to the
|
|
13
|
+
* backend SQL order filters (used by GetWebshopOrdersEndpoint). For every filter, the same StamhoofdFilter
|
|
14
|
+
* is run through both engines against the exact same set of orders, and both must return the same order ids.
|
|
15
|
+
*
|
|
16
|
+
* The in-memory engine runs on the decoded PrivateOrder structures (privateOrderWithTicketsFilterCompilers).
|
|
17
|
+
* The SQL engine runs a `SELECT * FROM webshop_orders WHERE <filter>` (orderFilterCompilers), the same way
|
|
18
|
+
* the endpoint builds its query.
|
|
19
|
+
*
|
|
20
|
+
* Filters that only exist in one engine are intentionally not covered here (no counterpart to compare to):
|
|
21
|
+
* - in-memory only: location, openBalance, ticketScanStatus, ticketScannedAt, ticketCount
|
|
22
|
+
* - SQL only: organizationId, updatedAt, paymentMethod
|
|
23
|
+
*/
|
|
24
|
+
describe('Order filters (in-memory vs backend SQL parity)', () => {
|
|
25
|
+
let organization: Organization;
|
|
26
|
+
let webshop: Webshop;
|
|
27
|
+
let nextNumber = 1;
|
|
28
|
+
|
|
29
|
+
beforeAll(async () => {
|
|
30
|
+
organization = await new OrganizationFactory({}).create();
|
|
31
|
+
webshop = await new WebshopFactory({ organizationId: organization.id }).create();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
beforeEach(async () => {
|
|
35
|
+
// Start every test from an empty order set so the in-memory universe and the SQL universe are identical.
|
|
36
|
+
await Database.delete('DELETE FROM `balance_item_payments`');
|
|
37
|
+
await Database.delete('DELETE FROM `payments`');
|
|
38
|
+
await Database.delete('DELETE FROM `balance_items`');
|
|
39
|
+
await Database.delete('DELETE FROM `webshop_orders`');
|
|
40
|
+
nextNumber = 1;
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// --- order creation helpers ------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
const address = () => Address.create({
|
|
46
|
+
street: 'Demostraat',
|
|
47
|
+
number: '15',
|
|
48
|
+
postalCode: '9000',
|
|
49
|
+
city: 'Gent',
|
|
50
|
+
country: Country.Belgium,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
/** A cart item with a fixed, deterministic price so totalPrice/amount are predictable. */
|
|
54
|
+
function cartItem(options: { productId?: string; productPriceId?: string; amount?: number; unitPrice?: number } = {}) {
|
|
55
|
+
const amount = options.amount ?? 1;
|
|
56
|
+
const unitPrice = options.unitPrice ?? 0;
|
|
57
|
+
return CartItem.create({
|
|
58
|
+
product: Product.create({ id: options.productId ?? 'product-default' }),
|
|
59
|
+
productPrice: ProductPrice.create({ id: options.productPriceId ?? 'price-default' }),
|
|
60
|
+
amount,
|
|
61
|
+
calculatedPrices: [CartItemPrice.create({ price: unitPrice * amount })],
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function orderData(options: {
|
|
66
|
+
firstName?: string;
|
|
67
|
+
lastName?: string;
|
|
68
|
+
email?: string;
|
|
69
|
+
phone?: string;
|
|
70
|
+
timeSlot?: WebshopTimeSlot | null;
|
|
71
|
+
checkoutMethod?: WebshopTakeoutMethod | null;
|
|
72
|
+
discountCodes?: DiscountCode[];
|
|
73
|
+
items?: CartItem[];
|
|
74
|
+
recordAnswers?: Map<string, RecordCheckboxAnswer | RecordTextAnswer | RecordChooseOneAnswer | RecordMultipleChoiceAnswer | RecordDateAnswer | RecordIntegerAnswer>;
|
|
75
|
+
} = {}): OrderData {
|
|
76
|
+
return OrderData.create({
|
|
77
|
+
customer: Customer.create({
|
|
78
|
+
firstName: options.firstName ?? 'John',
|
|
79
|
+
lastName: options.lastName ?? 'Doe',
|
|
80
|
+
email: options.email ?? 'john@example.com',
|
|
81
|
+
phone: options.phone ?? '+32412345678',
|
|
82
|
+
}),
|
|
83
|
+
timeSlot: options.timeSlot ?? null,
|
|
84
|
+
checkoutMethod: options.checkoutMethod ?? null,
|
|
85
|
+
discountCodes: options.discountCodes ?? [],
|
|
86
|
+
cart: Cart.create({ items: options.items ?? [] }),
|
|
87
|
+
recordAnswers: options.recordAnswers ?? new Map(),
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function createOrder(options: {
|
|
92
|
+
data?: OrderData;
|
|
93
|
+
status?: OrderStatus;
|
|
94
|
+
number?: number | null;
|
|
95
|
+
validAt?: Date | null;
|
|
96
|
+
createdAt?: Date;
|
|
97
|
+
} = {}): Promise<Order> {
|
|
98
|
+
const order = new Order();
|
|
99
|
+
order.organizationId = organization.id;
|
|
100
|
+
order.webshopId = webshop.id;
|
|
101
|
+
order.data = options.data ?? orderData();
|
|
102
|
+
order.number = options.number !== undefined ? options.number : nextNumber++;
|
|
103
|
+
order.status = options.status ?? OrderStatus.Created;
|
|
104
|
+
order.validAt = options.validAt !== undefined ? options.validAt : new Date();
|
|
105
|
+
if (options.createdAt) {
|
|
106
|
+
order.createdAt = options.createdAt;
|
|
107
|
+
}
|
|
108
|
+
await order.save();
|
|
109
|
+
return order;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Attaches a balance item to an order (needed for amountToPay and to hang payments off). */
|
|
113
|
+
async function addBalanceItem(order: Order, options: { unitPrice: number; amount?: number; pricePaid?: number }): Promise<BalanceItem> {
|
|
114
|
+
const balanceItem = new BalanceItem();
|
|
115
|
+
balanceItem.organizationId = organization.id;
|
|
116
|
+
balanceItem.orderId = order.id;
|
|
117
|
+
balanceItem.type = BalanceItemType.Order;
|
|
118
|
+
balanceItem.amount = options.amount ?? 1;
|
|
119
|
+
balanceItem.unitPrice = options.unitPrice;
|
|
120
|
+
balanceItem.pricePaid = options.pricePaid ?? 0;
|
|
121
|
+
await balanceItem.save();
|
|
122
|
+
return balanceItem;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function addPayment(balanceItem: BalanceItem, options: {
|
|
126
|
+
method: PaymentMethod;
|
|
127
|
+
price: number;
|
|
128
|
+
status?: PaymentStatus;
|
|
129
|
+
paidAt?: Date | null;
|
|
130
|
+
transferDescription?: string | null;
|
|
131
|
+
}): Promise<Payment> {
|
|
132
|
+
const payment = new Payment();
|
|
133
|
+
payment.method = options.method;
|
|
134
|
+
payment.status = options.status ?? PaymentStatus.Succeeded;
|
|
135
|
+
payment.organizationId = organization.id;
|
|
136
|
+
payment.price = options.price;
|
|
137
|
+
payment.paidAt = options.paidAt ?? null;
|
|
138
|
+
payment.transferDescription = options.transferDescription ?? null;
|
|
139
|
+
await payment.save();
|
|
140
|
+
|
|
141
|
+
const balanceItemPayment = new BalanceItemPayment();
|
|
142
|
+
balanceItemPayment.balanceItemId = balanceItem.id;
|
|
143
|
+
balanceItemPayment.paymentId = payment.id;
|
|
144
|
+
balanceItemPayment.price = options.price;
|
|
145
|
+
balanceItemPayment.organizationId = organization.id;
|
|
146
|
+
await balanceItemPayment.save();
|
|
147
|
+
|
|
148
|
+
return payment;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// --- parity harness --------------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
async function runInMemory(filter: StamhoofdFilter): Promise<string[]> {
|
|
154
|
+
const orders = await Order.where({ organizationId: organization.id });
|
|
155
|
+
const structures = await Order.getPrivateStructures(orders);
|
|
156
|
+
const runner = compileToInMemoryFilter(filter, privateOrderWithTicketsFilterCompilers);
|
|
157
|
+
return structures.filter(s => s.number !== null && runner(s)).map(s => s.id).sort();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function runSQL(filter: StamhoofdFilter): Promise<string[]> {
|
|
161
|
+
const query = SQL
|
|
162
|
+
.select(SQL.wildcard(Order.table))
|
|
163
|
+
.from(SQL.table(Order.table))
|
|
164
|
+
// Same base filter as GetWebshopOrdersEndpoint.buildQuery
|
|
165
|
+
.where(await compileToSQLFilter({ organizationId: organization.id, number: { $neq: null } }, orderFilterCompilers))
|
|
166
|
+
.where(await compileToSQLFilter(filter, orderFilterCompilers));
|
|
167
|
+
|
|
168
|
+
const rows = await query.fetch();
|
|
169
|
+
return Order.fromRows(rows, Order.table).map(o => o.id).sort();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Asserts that both engines return exactly the expected orders. Comparing to an explicit expected set
|
|
174
|
+
* (instead of only comparing the engines to each other) also guards against both engines being wrong
|
|
175
|
+
* in the same way, or a filter accidentally matching everything / nothing.
|
|
176
|
+
*/
|
|
177
|
+
async function expectFilter(filter: StamhoofdFilter, expected: Order[]) {
|
|
178
|
+
const expectedIds = [...new Set(expected.map(o => o.id))].sort();
|
|
179
|
+
const inMemoryIds = await runInMemory(filter);
|
|
180
|
+
const sqlIds = await runSQL(filter);
|
|
181
|
+
|
|
182
|
+
expect(inMemoryIds, 'in-memory result should match expected').toEqual(expectedIds);
|
|
183
|
+
expect(sqlIds, 'backend SQL result should match expected').toEqual(expectedIds);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// --- simple columns --------------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
describe('id', () => {
|
|
189
|
+
it('$eq matches a single order', async () => {
|
|
190
|
+
const a = await createOrder();
|
|
191
|
+
const b = await createOrder();
|
|
192
|
+
await expectFilter({ id: { $eq: a.id } }, [a]);
|
|
193
|
+
await expectFilter({ id: { $eq: b.id } }, [b]);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('$in matches multiple orders', async () => {
|
|
197
|
+
const a = await createOrder();
|
|
198
|
+
const b = await createOrder();
|
|
199
|
+
const c = await createOrder();
|
|
200
|
+
await expectFilter({ id: { $in: [a.id, c.id] } }, [a, c]);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('$neq excludes a single order', async () => {
|
|
204
|
+
const a = await createOrder();
|
|
205
|
+
const b = await createOrder();
|
|
206
|
+
await expectFilter({ id: { $neq: a.id } }, [b]);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe('webshopId', () => {
|
|
211
|
+
it('$eq matches orders of the webshop', async () => {
|
|
212
|
+
const a = await createOrder();
|
|
213
|
+
const b = await createOrder();
|
|
214
|
+
await expectFilter({ webshopId: { $eq: webshop.id } }, [a, b]);
|
|
215
|
+
await expectFilter({ webshopId: { $eq: 'does-not-exist' } }, []);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe('status', () => {
|
|
220
|
+
it('$eq / $in / $neq on the enum', async () => {
|
|
221
|
+
const created = await createOrder({ status: OrderStatus.Created });
|
|
222
|
+
const prepared = await createOrder({ status: OrderStatus.Prepared });
|
|
223
|
+
const completed = await createOrder({ status: OrderStatus.Completed });
|
|
224
|
+
|
|
225
|
+
await expectFilter({ status: { $eq: OrderStatus.Created } }, [created]);
|
|
226
|
+
await expectFilter({ status: { $in: [OrderStatus.Created, OrderStatus.Completed] } }, [created, completed]);
|
|
227
|
+
await expectFilter({ status: { $neq: OrderStatus.Prepared } }, [created, completed]);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
describe('number', () => {
|
|
232
|
+
it('numeric comparisons', async () => {
|
|
233
|
+
const a = await createOrder({ number: 10 });
|
|
234
|
+
const b = await createOrder({ number: 20 });
|
|
235
|
+
const c = await createOrder({ number: 30 });
|
|
236
|
+
|
|
237
|
+
await expectFilter({ number: { $eq: 20 } }, [b]);
|
|
238
|
+
await expectFilter({ number: { $gt: 15 } }, [b, c]);
|
|
239
|
+
await expectFilter({ number: { $lt: 25 } }, [a, b]);
|
|
240
|
+
await expectFilter({ number: { $in: [10, 30] } }, [a, c]);
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
describe('createdAt', () => {
|
|
245
|
+
it('date comparisons', async () => {
|
|
246
|
+
const older = await createOrder({ createdAt: new Date('2023-01-01T12:00:00Z') });
|
|
247
|
+
const newer = await createOrder({ createdAt: new Date('2024-01-01T12:00:00Z') });
|
|
248
|
+
|
|
249
|
+
await expectFilter({ createdAt: { $lt: new Date('2023-06-01T00:00:00Z') } }, [older]);
|
|
250
|
+
await expectFilter({ createdAt: { $gt: new Date('2023-06-01T00:00:00Z') } }, [newer]);
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
describe('validAt', () => {
|
|
255
|
+
it('$eq null / $neq null', async () => {
|
|
256
|
+
const valid = await createOrder({ validAt: new Date('2024-01-01T12:00:00Z') });
|
|
257
|
+
const invalid = await createOrder({ validAt: null });
|
|
258
|
+
|
|
259
|
+
await expectFilter({ validAt: { $eq: null } }, [invalid]);
|
|
260
|
+
await expectFilter({ validAt: { $neq: null } }, [valid]);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('date comparison', async () => {
|
|
264
|
+
const older = await createOrder({ validAt: new Date('2023-01-01T12:00:00Z') });
|
|
265
|
+
const newer = await createOrder({ validAt: new Date('2024-01-01T12:00:00Z') });
|
|
266
|
+
|
|
267
|
+
await expectFilter({ validAt: { $gt: new Date('2023-06-01T00:00:00Z') } }, [newer]);
|
|
268
|
+
await expectFilter({ validAt: { $lt: new Date('2023-06-01T00:00:00Z') } }, [older]);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// --- customer JSON ---------------------------------------------------------------------------------
|
|
273
|
+
|
|
274
|
+
describe('name', () => {
|
|
275
|
+
it('$eq and $contains (case-insensitive)', async () => {
|
|
276
|
+
const john = await createOrder({ data: orderData({ firstName: 'John', lastName: 'Doe' }) });
|
|
277
|
+
const jane = await createOrder({ data: orderData({ firstName: 'Jane', lastName: 'Roe' }) });
|
|
278
|
+
|
|
279
|
+
await expectFilter({ name: { $eq: 'John Doe' } }, [john]);
|
|
280
|
+
await expectFilter({ name: { $eq: 'john doe' } }, [john]);
|
|
281
|
+
await expectFilter({ name: { $contains: 'oe' } }, [john, jane]);
|
|
282
|
+
await expectFilter({ name: { $contains: 'jane' } }, [jane]);
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
describe('email', () => {
|
|
287
|
+
it('$eq and $contains', async () => {
|
|
288
|
+
const a = await createOrder({ data: orderData({ email: 'alice@example.com' }) });
|
|
289
|
+
const b = await createOrder({ data: orderData({ email: 'bob@other.org' }) });
|
|
290
|
+
|
|
291
|
+
await expectFilter({ email: { $eq: 'alice@example.com' } }, [a]);
|
|
292
|
+
await expectFilter({ email: { $contains: 'example.com' } }, [a]);
|
|
293
|
+
await expectFilter({ email: { $contains: '@' } }, [a, b]);
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
describe('phone', () => {
|
|
298
|
+
it('$contains and $eq on a set phone number', async () => {
|
|
299
|
+
const a = await createOrder({ data: orderData({ phone: '+32412345678' }) });
|
|
300
|
+
const b = await createOrder({ data: orderData({ phone: '+32498765432' }) });
|
|
301
|
+
|
|
302
|
+
await expectFilter({ phone: { $eq: '+32412345678' } }, [a]);
|
|
303
|
+
await expectFilter({ phone: { $contains: '4123' } }, [a]);
|
|
304
|
+
await expectFilter({ phone: { $contains: '3' } }, [a, b]);
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// --- cached columns --------------------------------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
describe('totalPrice', () => {
|
|
311
|
+
it('numeric comparisons', async () => {
|
|
312
|
+
const cheap = await createOrder({ data: orderData({ items: [cartItem({ unitPrice: 5_00, amount: 1 })] }) });
|
|
313
|
+
const expensive = await createOrder({ data: orderData({ items: [cartItem({ unitPrice: 50_00, amount: 1 })] }) });
|
|
314
|
+
|
|
315
|
+
await expectFilter({ totalPrice: { $eq: 5_00 } }, [cheap]);
|
|
316
|
+
await expectFilter({ totalPrice: { $gt: 10_00 } }, [expensive]);
|
|
317
|
+
await expectFilter({ totalPrice: { $lt: 10_00 } }, [cheap]);
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
describe('amount', () => {
|
|
322
|
+
it('numeric comparisons', async () => {
|
|
323
|
+
const few = await createOrder({ data: orderData({ items: [cartItem({ amount: 1, unitPrice: 1_00 })] }) });
|
|
324
|
+
const many = await createOrder({ data: orderData({ items: [cartItem({ amount: 5, unitPrice: 1_00 })] }) });
|
|
325
|
+
|
|
326
|
+
await expectFilter({ amount: { $eq: 1 } }, [few]);
|
|
327
|
+
await expectFilter({ amount: { $gt: 3 } }, [many]);
|
|
328
|
+
await expectFilter({ amount: { $lte: 1 } }, [few]);
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
// --- time slot -------------------------------------------------------------------------------------
|
|
333
|
+
|
|
334
|
+
describe('time slot', () => {
|
|
335
|
+
const slot = (options: { date: Date; startTime?: number; endTime?: number }) => WebshopTimeSlot.create({
|
|
336
|
+
date: options.date,
|
|
337
|
+
startTime: options.startTime ?? 12 * 60,
|
|
338
|
+
endTime: options.endTime ?? 14 * 60,
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('timeSlotDate $eq null matches orders without a time slot', async () => {
|
|
342
|
+
const withSlot = await createOrder({ data: orderData({ timeSlot: slot({ date: new Date('2024-05-10T00:00:00Z') }) }) });
|
|
343
|
+
const without = await createOrder({ data: orderData({ timeSlot: null }) });
|
|
344
|
+
|
|
345
|
+
await expectFilter({ timeSlotDate: { $eq: null } }, [without]);
|
|
346
|
+
await expectFilter({ timeSlotDate: { $neq: null } }, [withSlot]);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
// timeSlotStartTime / timeSlotEndTime hold numbers (minutes since midnight). Numeric comparisons
|
|
350
|
+
// must work in both engines, and a missing time slot must behave like SQL NULL: matched by $eq null
|
|
351
|
+
// and by $lt (NULL is the smallest), not matched by $gt or $neq null.
|
|
352
|
+
it('timeSlotStartTime / timeSlotEndTime comparisons and null parity', async () => {
|
|
353
|
+
const morning = await createOrder({ data: orderData({ timeSlot: slot({ date: new Date('2024-05-10T00:00:00Z'), startTime: 9 * 60, endTime: 11 * 60 }) }) });
|
|
354
|
+
const evening = await createOrder({ data: orderData({ timeSlot: slot({ date: new Date('2024-05-10T00:00:00Z'), startTime: 18 * 60, endTime: 20 * 60 }) }) });
|
|
355
|
+
const without = await createOrder({ data: orderData({ timeSlot: null }) });
|
|
356
|
+
|
|
357
|
+
await expectFilter({ timeSlotStartTime: { $eq: null } }, [without]);
|
|
358
|
+
await expectFilter({ timeSlotStartTime: { $neq: null } }, [morning, evening]);
|
|
359
|
+
await expectFilter({ timeSlotStartTime: { $gt: 12 * 60 } }, [evening]);
|
|
360
|
+
await expectFilter({ timeSlotStartTime: { $lt: 12 * 60 } }, [morning, without]);
|
|
361
|
+
await expectFilter({ timeSlotEndTime: { $gt: 12 * 60 } }, [evening]);
|
|
362
|
+
await expectFilter({ timeSlotEndTime: { $lt: 12 * 60 } }, [morning, without]);
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
// --- checkout method -------------------------------------------------------------------------------
|
|
367
|
+
|
|
368
|
+
describe('checkout method', () => {
|
|
369
|
+
const takeout = (id: string, name = 'Pickup point') => WebshopTakeoutMethod.create({
|
|
370
|
+
id,
|
|
371
|
+
name,
|
|
372
|
+
address: address(),
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it('checkoutMethod (type) and checkoutMethodId, incl null parity', async () => {
|
|
376
|
+
const a = await createOrder({ data: orderData({ checkoutMethod: takeout('method-a') }) });
|
|
377
|
+
const b = await createOrder({ data: orderData({ checkoutMethod: takeout('method-b') }) });
|
|
378
|
+
const without = await createOrder({ data: orderData({ checkoutMethod: null }) });
|
|
379
|
+
|
|
380
|
+
await expectFilter({ checkoutMethodId: { $eq: 'method-a' } }, [a]);
|
|
381
|
+
await expectFilter({ checkoutMethodId: { $eq: null } }, [without]);
|
|
382
|
+
await expectFilter({ checkoutMethod: { $in: [CheckoutMethodType.Takeout] } }, [a, b]);
|
|
383
|
+
await expectFilter({ checkoutMethod: { $eq: null } }, [without]);
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
// --- discount codes --------------------------------------------------------------------------------
|
|
388
|
+
|
|
389
|
+
describe('discountCodes.code', () => {
|
|
390
|
+
// Codes are stored with mixed case on purpose: both $eq and $in on a JSON array must be
|
|
391
|
+
// case-insensitive, exactly like the in-memory engine.
|
|
392
|
+
it('$eq and $in match the codes in the array (case-insensitive)', async () => {
|
|
393
|
+
const summer = await createOrder({ data: orderData({ discountCodes: [DiscountCode.create({ code: 'SUMMER' })] }) });
|
|
394
|
+
const summerLower = await createOrder({ data: orderData({ discountCodes: [DiscountCode.create({ code: 'summer' })] }) });
|
|
395
|
+
const summerCombination = await createOrder({ data: orderData({ discountCodes: [DiscountCode.create({ code: 'suMMER' })] }) });
|
|
396
|
+
|
|
397
|
+
const winter = await createOrder({ data: orderData({ discountCodes: [DiscountCode.create({ code: 'WINTER' })] }) });
|
|
398
|
+
|
|
399
|
+
const none = await createOrder({ data: orderData({ discountCodes: [] }) });
|
|
400
|
+
|
|
401
|
+
await expectFilter({ discountCodes: { code: { $eq: 'summer' } } }, [summer, summerLower, summerCombination]);
|
|
402
|
+
await expectFilter({ discountCodes: { code: { $in: ['summer', 'winter'] } } }, [summer, summerLower, summerCombination, winter]);
|
|
403
|
+
await expectFilter({ discountCodes: { code: { $in: ['SUMMER', 'WINTER'] } } }, [summer, summerLower, summerCombination, winter]);
|
|
404
|
+
await expectFilter({ discountCodes: { code: { $in: ['suMmer', 'winTEr'] } } }, [summer, summerLower, summerCombination, winter]);
|
|
405
|
+
expect(none.id).toBeDefined();
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
// --- cart items ($elemMatch) -----------------------------------------------------------------------
|
|
410
|
+
|
|
411
|
+
describe('items', () => {
|
|
412
|
+
it('$elemMatch on product / productPrice / amount', async () => {
|
|
413
|
+
const apple = await createOrder({ data: orderData({ items: [cartItem({ productId: 'apple', productPriceId: 'apple-price', amount: 2, unitPrice: 1_00 })] }) });
|
|
414
|
+
const pear = await createOrder({ data: orderData({ items: [cartItem({ productId: 'pear', productPriceId: 'pear-price', amount: 5, unitPrice: 1_00 })] }) });
|
|
415
|
+
|
|
416
|
+
await expectFilter({ items: { $elemMatch: { product: { id: { $eq: 'apple' } } } } }, [apple]);
|
|
417
|
+
await expectFilter({ items: { $elemMatch: { productPrice: { id: { $eq: 'pear-price' } } } } }, [pear]);
|
|
418
|
+
await expectFilter({ items: { $elemMatch: { amount: { $gt: 3 } } } }, [pear]);
|
|
419
|
+
await expectFilter({ items: { $elemMatch: { product: { id: { $in: ['apple', 'pear'] } } } } }, [apple, pear]);
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
// --- record answers --------------------------------------------------------------------------------
|
|
424
|
+
|
|
425
|
+
describe('record answers', () => {
|
|
426
|
+
const RID = 'record-id-1';
|
|
427
|
+
|
|
428
|
+
const checkbox = (selected: boolean) => new Map([[RID, RecordCheckboxAnswer.create({ settings: RecordSettings.create({ id: RID, type: RecordType.Checkbox }), selected })]]);
|
|
429
|
+
const text = (value: string) => new Map([[RID, RecordTextAnswer.create({ settings: RecordSettings.create({ id: RID, type: RecordType.Text }), value })]]);
|
|
430
|
+
const chooseOne = (choiceId: string | null) => new Map([[RID, RecordChooseOneAnswer.create({ settings: RecordSettings.create({ id: RID, type: RecordType.ChooseOne }), selectedChoice: choiceId === null ? null : RecordChoice.create({ id: choiceId }) })]]);
|
|
431
|
+
const multipleChoice = (choiceIds: string[]) => new Map([[RID, RecordMultipleChoiceAnswer.create({ settings: RecordSettings.create({ id: RID, type: RecordType.MultipleChoice }), selectedChoices: choiceIds.map(id => RecordChoice.create({ id })) })]]);
|
|
432
|
+
const date = (dateValue: Date | null) => new Map([[RID, RecordDateAnswer.create({ settings: RecordSettings.create({ id: RID, type: RecordType.Date }), dateValue })]]);
|
|
433
|
+
const integer = (value: number | null) => new Map([[RID, RecordIntegerAnswer.create({ settings: RecordSettings.create({ id: RID, type: RecordType.Integer }), value })]]);
|
|
434
|
+
|
|
435
|
+
it('checkbox: selected', async () => {
|
|
436
|
+
const checked = await createOrder({ data: orderData({ recordAnswers: checkbox(true) }) });
|
|
437
|
+
const unchecked = await createOrder({ data: orderData({ recordAnswers: checkbox(false) }) });
|
|
438
|
+
|
|
439
|
+
await expectFilter({ recordAnswers: { [RID]: { selected: { $eq: true } } } }, [checked]);
|
|
440
|
+
await expectFilter({ recordAnswers: { [RID]: { selected: { $eq: false } } } }, [unchecked]);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it('text value: $contains and $eq', async () => {
|
|
444
|
+
const hello = await createOrder({ data: orderData({ recordAnswers: text('say hello world') }) });
|
|
445
|
+
const bye = await createOrder({ data: orderData({ recordAnswers: text('goodbye') }) });
|
|
446
|
+
|
|
447
|
+
await expectFilter({ recordAnswers: { [RID]: { value: { $contains: 'hello' } } } }, [hello]);
|
|
448
|
+
await expectFilter({ recordAnswers: { [RID]: { value: { $eq: 'goodbye' } } } }, [bye]);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it('single choice: selectedChoice.id', async () => {
|
|
452
|
+
const a = await createOrder({ data: orderData({ recordAnswers: chooseOne('choice-a') }) });
|
|
453
|
+
const b = await createOrder({ data: orderData({ recordAnswers: chooseOne('choice-b') }) });
|
|
454
|
+
|
|
455
|
+
await expectFilter({ recordAnswers: { [RID]: { selectedChoice: { id: { $eq: 'choice-a' } } } } }, [a]);
|
|
456
|
+
await expectFilter({ recordAnswers: { [RID]: { selectedChoice: { id: { $in: ['choice-a', 'choice-b'] } } } } }, [a, b]);
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it('multiple choice: selectedChoices.id', async () => {
|
|
460
|
+
const ab = await createOrder({ data: orderData({ recordAnswers: multipleChoice(['choice-a', 'choice-b']) }) });
|
|
461
|
+
const c = await createOrder({ data: orderData({ recordAnswers: multipleChoice(['choice-c']) }) });
|
|
462
|
+
|
|
463
|
+
await expectFilter({ recordAnswers: { [RID]: { selectedChoices: { id: { $in: ['choice-a'] } } } } }, [ab]);
|
|
464
|
+
await expectFilter({ recordAnswers: { [RID]: { selectedChoices: { id: { $in: ['choice-c'] } } } } }, [c]);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it('date value: comparisons and $eq null', async () => {
|
|
468
|
+
const stored = await createOrder({ data: orderData({ recordAnswers: date(new Date('2023-06-10T14:30:00Z')) }) });
|
|
469
|
+
const later = await createOrder({ data: orderData({ recordAnswers: date(new Date('2023-06-25T14:30:00Z')) }) });
|
|
470
|
+
const nullDate = await createOrder({ data: orderData({ recordAnswers: date(null) }) });
|
|
471
|
+
|
|
472
|
+
await expectFilter({ recordAnswers: { [RID]: { dateValue: { $gt: new Date('2023-06-20T00:00:00Z') } } } }, [later]);
|
|
473
|
+
await expectFilter({ recordAnswers: { [RID]: { dateValue: { $eq: null } } } }, [nullDate]);
|
|
474
|
+
expect(stored.id).toBeDefined();
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
// An unanswered record question (missing map key) or a null answer value must behave in memory exactly like the SQL NULL the JSON extraction yields.
|
|
478
|
+
describe('missing / null answer parity (the SQL NULL behaviour)', () => {
|
|
479
|
+
it('$eq null matches both a null value and an unanswered question', async () => {
|
|
480
|
+
const nullValue = await createOrder({ data: orderData({ recordAnswers: text('') }) });
|
|
481
|
+
// Overwrite with an actual null text value
|
|
482
|
+
const nullAnswer = await createOrder({ data: orderData({ recordAnswers: new Map([[RID, RecordTextAnswer.create({ settings: RecordSettings.create({ id: RID, type: RecordType.Text }), value: null })]]) }) });
|
|
483
|
+
const unanswered = await createOrder({ data: orderData({ recordAnswers: new Map() }) });
|
|
484
|
+
const answered = await createOrder({ data: orderData({ recordAnswers: text('something') }) });
|
|
485
|
+
|
|
486
|
+
await expectFilter({ recordAnswers: { [RID]: { value: { $eq: null } } } }, [nullAnswer, unanswered]);
|
|
487
|
+
expect(nullValue.id).toBeDefined();
|
|
488
|
+
expect(answered.id).toBeDefined();
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it('$lt matches smaller values, null and unanswered (null is the smallest)', async () => {
|
|
492
|
+
const small = await createOrder({ data: orderData({ recordAnswers: date(new Date('2023-01-01T00:00:00Z')) }) });
|
|
493
|
+
const big = await createOrder({ data: orderData({ recordAnswers: date(new Date('2025-01-01T00:00:00Z')) }) });
|
|
494
|
+
const nullAnswer = await createOrder({ data: orderData({ recordAnswers: date(null) }) });
|
|
495
|
+
const unanswered = await createOrder({ data: orderData({ recordAnswers: new Map() }) });
|
|
496
|
+
|
|
497
|
+
await expectFilter({ recordAnswers: { [RID]: { dateValue: { $lt: new Date('2024-01-01T00:00:00Z') } } } }, [small, nullAnswer, unanswered]);
|
|
498
|
+
expect(big.id).toBeDefined();
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it('$contains never matches a null value or an unanswered question', async () => {
|
|
502
|
+
const match = await createOrder({ data: orderData({ recordAnswers: text('hello world') }) });
|
|
503
|
+
const nullAnswer = await createOrder({ data: orderData({ recordAnswers: new Map([[RID, RecordTextAnswer.create({ settings: RecordSettings.create({ id: RID, type: RecordType.Text }), value: null })]]) }) });
|
|
504
|
+
const unanswered = await createOrder({ data: orderData({ recordAnswers: new Map() }) });
|
|
505
|
+
|
|
506
|
+
await expectFilter({ recordAnswers: { [RID]: { value: { $contains: 'hello' } } } }, [match]);
|
|
507
|
+
expect(nullAnswer.id).toBeDefined();
|
|
508
|
+
expect(unanswered.id).toBeDefined();
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
it('number value $lt matches smaller values, null and unanswered', async () => {
|
|
512
|
+
const small = await createOrder({ data: orderData({ recordAnswers: integer(5) }) });
|
|
513
|
+
const big = await createOrder({ data: orderData({ recordAnswers: integer(15) }) });
|
|
514
|
+
const nullValue = await createOrder({ data: orderData({ recordAnswers: integer(null) }) });
|
|
515
|
+
const unanswered = await createOrder({ data: orderData({ recordAnswers: new Map() }) });
|
|
516
|
+
|
|
517
|
+
await expectFilter({ recordAnswers: { [RID]: { value: { $lt: 10 } } } }, [small, nullValue, unanswered]);
|
|
518
|
+
expect(big.id).toBeDefined();
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
it('date value $gt matches later values but not null or unanswered', async () => {
|
|
522
|
+
const later = await createOrder({ data: orderData({ recordAnswers: date(new Date('2023-06-10T00:00:00Z')) }) });
|
|
523
|
+
const earlier = await createOrder({ data: orderData({ recordAnswers: date(new Date('2023-06-01T00:00:00Z')) }) });
|
|
524
|
+
const nullValue = await createOrder({ data: orderData({ recordAnswers: date(null) }) });
|
|
525
|
+
const unanswered = await createOrder({ data: orderData({ recordAnswers: new Map() }) });
|
|
526
|
+
|
|
527
|
+
await expectFilter({ recordAnswers: { [RID]: { dateValue: { $gt: new Date('2023-06-05T00:00:00Z') } } } }, [later]);
|
|
528
|
+
expect(earlier.id).toBeDefined();
|
|
529
|
+
expect(nullValue.id).toBeDefined();
|
|
530
|
+
expect(unanswered.id).toBeDefined();
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
it('date value $eq null matches null and unanswered', async () => {
|
|
534
|
+
const stored = await createOrder({ data: orderData({ recordAnswers: date(new Date('2023-06-10T00:00:00Z')) }) });
|
|
535
|
+
const nullValue = await createOrder({ data: orderData({ recordAnswers: date(null) }) });
|
|
536
|
+
const unanswered = await createOrder({ data: orderData({ recordAnswers: new Map() }) });
|
|
537
|
+
|
|
538
|
+
await expectFilter({ recordAnswers: { [RID]: { dateValue: { $eq: null } } } }, [nullValue, unanswered]);
|
|
539
|
+
expect(stored.id).toBeDefined();
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
it('checkbox selected: $in [null, true] matches unanswered, $in [true] does not', async () => {
|
|
543
|
+
const checked = await createOrder({ data: orderData({ recordAnswers: checkbox(true) }) });
|
|
544
|
+
const unchecked = await createOrder({ data: orderData({ recordAnswers: checkbox(false) }) });
|
|
545
|
+
const unanswered = await createOrder({ data: orderData({ recordAnswers: new Map() }) });
|
|
546
|
+
|
|
547
|
+
await expectFilter({ recordAnswers: { [RID]: { selected: { $in: [null, true] } } } }, [checked, unanswered]);
|
|
548
|
+
await expectFilter({ recordAnswers: { [RID]: { selected: { $in: [true] } } } }, [checked]);
|
|
549
|
+
expect(unchecked.id).toBeDefined();
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
it('single choice: $in [null, choice] matches a null choice and unanswered', async () => {
|
|
553
|
+
const a = await createOrder({ data: orderData({ recordAnswers: chooseOne('choice-a') }) });
|
|
554
|
+
const b = await createOrder({ data: orderData({ recordAnswers: chooseOne('choice-b') }) });
|
|
555
|
+
const nullChoice = await createOrder({ data: orderData({ recordAnswers: chooseOne(null) }) });
|
|
556
|
+
const unanswered = await createOrder({ data: orderData({ recordAnswers: new Map() }) });
|
|
557
|
+
|
|
558
|
+
await expectFilter({ recordAnswers: { [RID]: { selectedChoice: { id: { $in: [null, 'choice-a'] } } } } }, [a, nullChoice, unanswered]);
|
|
559
|
+
expect(b.id).toBeDefined();
|
|
560
|
+
});
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
// Negated filters on an empty record answer (null value, or a question that was never answered).
|
|
564
|
+
// The dashboard builds "not equal to x" and "does not contain x" as $not { ... }. A missing or null
|
|
565
|
+
// answer is not equal to and does not contain any concrete value, so it must match — in both engines.
|
|
566
|
+
describe('negated filters on missing or null values', () => {
|
|
567
|
+
const nullText = () => new Map([[RID, RecordTextAnswer.create({ settings: RecordSettings.create({ id: RID, type: RecordType.Text }), value: null })]]);
|
|
568
|
+
|
|
569
|
+
it('$not { $eq } (NotEquals) matches null and unanswered in both engines', async () => {
|
|
570
|
+
const x = await createOrder({ data: orderData({ recordAnswers: text('x') }) });
|
|
571
|
+
const y = await createOrder({ data: orderData({ recordAnswers: text('y') }) });
|
|
572
|
+
const nullValue = await createOrder({ data: orderData({ recordAnswers: nullText() }) });
|
|
573
|
+
const unanswered = await createOrder({ data: orderData({ recordAnswers: new Map() }) });
|
|
574
|
+
|
|
575
|
+
await expectFilter({ recordAnswers: { [RID]: { $not: { value: { $eq: 'x' } } } } }, [y, nullValue, unanswered]);
|
|
576
|
+
expect(x.id).toBeDefined();
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
it('$not { $contains } (NotContains) matches null and unanswered in both engines', async () => {
|
|
580
|
+
const withX = await createOrder({ data: orderData({ recordAnswers: text('fox') }) });
|
|
581
|
+
const withoutX = await createOrder({ data: orderData({ recordAnswers: text('dog') }) });
|
|
582
|
+
const nullValue = await createOrder({ data: orderData({ recordAnswers: nullText() }) });
|
|
583
|
+
const unanswered = await createOrder({ data: orderData({ recordAnswers: new Map() }) });
|
|
584
|
+
|
|
585
|
+
await expectFilter({ recordAnswers: { [RID]: { $not: { value: { $contains: 'x' } } } } }, [withoutX, nullValue, unanswered]);
|
|
586
|
+
expect(withX.id).toBeDefined();
|
|
587
|
+
});
|
|
588
|
+
});
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
// --- payments ($elemMatch) -------------------------------------------------------------------------
|
|
592
|
+
|
|
593
|
+
describe('payments', () => {
|
|
594
|
+
it('$elemMatch on method / price / transferDescription, ignoring failed payments', async () => {
|
|
595
|
+
const transferOrder = await createOrder();
|
|
596
|
+
const transferItem = await addBalanceItem(transferOrder, { unitPrice: 10_00, pricePaid: 10_00 });
|
|
597
|
+
await addPayment(transferItem, { method: PaymentMethod.Transfer, price: 10_00, transferDescription: '+++123/456/789+++' });
|
|
598
|
+
|
|
599
|
+
const cardOrder = await createOrder();
|
|
600
|
+
const cardItem = await addBalanceItem(cardOrder, { unitPrice: 25_00, pricePaid: 25_00 });
|
|
601
|
+
await addPayment(cardItem, { method: PaymentMethod.CreditCard, price: 25_00 });
|
|
602
|
+
|
|
603
|
+
// Order whose only payment failed: both engines must ignore the failed payment.
|
|
604
|
+
const failedOrder = await createOrder();
|
|
605
|
+
const failedItem = await addBalanceItem(failedOrder, { unitPrice: 5_00, pricePaid: 0 });
|
|
606
|
+
await addPayment(failedItem, { method: PaymentMethod.Transfer, price: 5_00, status: PaymentStatus.Failed });
|
|
607
|
+
|
|
608
|
+
await expectFilter({ payments: { $elemMatch: { method: { $eq: PaymentMethod.Transfer } } } }, [transferOrder]);
|
|
609
|
+
await expectFilter({ payments: { $elemMatch: { price: { $gt: 20_00 } } } }, [cardOrder]);
|
|
610
|
+
await expectFilter({ payments: { $elemMatch: { transferDescription: { $contains: '123' } } } }, [transferOrder]);
|
|
611
|
+
});
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
// --- amountToPay -----------------------------------------------------------------------------------
|
|
615
|
+
|
|
616
|
+
describe('amountToPay', () => {
|
|
617
|
+
// amountToPay = totalPrice - sum(balance item pricePaid). Every order here has a balance item so the
|
|
618
|
+
// SQL SUM is not NULL (an order with no balance items would make amountToPay NULL in SQL but
|
|
619
|
+
// totalPrice in memory, which is a separate, documented divergence).
|
|
620
|
+
it('numeric comparisons', async () => {
|
|
621
|
+
const fullyPaid = await createOrder({ data: orderData({ items: [cartItem({ unitPrice: 10_00, amount: 1 })] }) });
|
|
622
|
+
await addBalanceItem(fullyPaid, { unitPrice: 10_00, pricePaid: 10_00 });
|
|
623
|
+
|
|
624
|
+
const partlyPaid = await createOrder({ data: orderData({ items: [cartItem({ unitPrice: 10_00, amount: 1 })] }) });
|
|
625
|
+
await addBalanceItem(partlyPaid, { unitPrice: 10_00, pricePaid: 4_00 });
|
|
626
|
+
|
|
627
|
+
const unpaid = await createOrder({ data: orderData({ items: [cartItem({ unitPrice: 10_00, amount: 1 })] }) });
|
|
628
|
+
await addBalanceItem(unpaid, { unitPrice: 10_00, pricePaid: 0 });
|
|
629
|
+
|
|
630
|
+
await expectFilter({ amountToPay: { $eq: 0 } }, [fullyPaid]);
|
|
631
|
+
await expectFilter({ amountToPay: { $gt: 0 } }, [partlyPaid, unpaid]);
|
|
632
|
+
await expectFilter({ amountToPay: { $eq: 10_00 } }, [unpaid]);
|
|
633
|
+
});
|
|
634
|
+
});
|
|
635
|
+
});
|