@stamhoofd/backend 2.139.0 → 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/endpoints/auth/MFA.security.test.ts +76 -1
- package/src/endpoints/auth/VerifyEmailEndpoint.ts +14 -0
- package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemBreakdownEndpoint.test.ts +12 -12
- package/src/endpoints/organization/dashboard/payments/GetPaymentBreakdownEndpoint.test.ts +11 -11
- package/src/helpers/TwoFactorHelper.ts +1 -1
- package/src/helpers/streamForBreakdown.ts +2 -2
- package/src/services/DocumentRenderService.test.ts +72 -1
- package/src/services/PaymentService.ts +8 -0
- package/src/sql-filters/orders.ts +6 -2
- package/tests/filters/orders.test.ts +635 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stamhoofd/backend",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.140.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"exports": {
|
|
@@ -65,20 +65,20 @@
|
|
|
65
65
|
"@simonbackx/simple-errors": "1.5.0",
|
|
66
66
|
"@simonbackx/simple-logging": "1.0.1",
|
|
67
67
|
"@simplewebauthn/server": "13.3.2",
|
|
68
|
-
"@stamhoofd/backend-env": "2.
|
|
69
|
-
"@stamhoofd/backend-i18n": "2.
|
|
70
|
-
"@stamhoofd/backend-middleware": "2.
|
|
71
|
-
"@stamhoofd/crons": "2.
|
|
72
|
-
"@stamhoofd/email": "2.
|
|
73
|
-
"@stamhoofd/excel-writer": "2.
|
|
74
|
-
"@stamhoofd/logging": "2.
|
|
75
|
-
"@stamhoofd/models": "2.
|
|
76
|
-
"@stamhoofd/object-differ": "2.
|
|
77
|
-
"@stamhoofd/queues": "2.
|
|
78
|
-
"@stamhoofd/sql": "2.
|
|
79
|
-
"@stamhoofd/structures": "2.
|
|
80
|
-
"@stamhoofd/types": "2.
|
|
81
|
-
"@stamhoofd/utility": "2.
|
|
68
|
+
"@stamhoofd/backend-env": "2.140.0",
|
|
69
|
+
"@stamhoofd/backend-i18n": "2.140.0",
|
|
70
|
+
"@stamhoofd/backend-middleware": "2.140.0",
|
|
71
|
+
"@stamhoofd/crons": "2.140.0",
|
|
72
|
+
"@stamhoofd/email": "2.140.0",
|
|
73
|
+
"@stamhoofd/excel-writer": "2.140.0",
|
|
74
|
+
"@stamhoofd/logging": "2.140.0",
|
|
75
|
+
"@stamhoofd/models": "2.140.0",
|
|
76
|
+
"@stamhoofd/object-differ": "2.140.0",
|
|
77
|
+
"@stamhoofd/queues": "2.140.0",
|
|
78
|
+
"@stamhoofd/sql": "2.140.0",
|
|
79
|
+
"@stamhoofd/structures": "2.140.0",
|
|
80
|
+
"@stamhoofd/types": "2.140.0",
|
|
81
|
+
"@stamhoofd/utility": "2.140.0",
|
|
82
82
|
"archiver": "7.0.1",
|
|
83
83
|
"argon2": "0.44.0",
|
|
84
84
|
"axios": "1.18.1",
|
|
@@ -102,7 +102,7 @@
|
|
|
102
102
|
"uuid": "14.0.1"
|
|
103
103
|
},
|
|
104
104
|
"devDependencies": {
|
|
105
|
-
"@stamhoofd/test-utils": "2.
|
|
105
|
+
"@stamhoofd/test-utils": "2.140.0",
|
|
106
106
|
"@types/cookie": "0.6.0",
|
|
107
107
|
"@types/luxon": "3.7.2",
|
|
108
108
|
"@types/mailparser": "3.4.6",
|
|
@@ -117,5 +117,5 @@
|
|
|
117
117
|
"publishConfig": {
|
|
118
118
|
"access": "public"
|
|
119
119
|
},
|
|
120
|
-
"gitHead": "
|
|
120
|
+
"gitHead": "532463b65f5fa152df23744b1f097ca48c2de3a1"
|
|
121
121
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Request } from '@simonbackx/simple-endpoints';
|
|
2
2
|
import { isSimpleError, isSimpleErrors, SimpleError } from '@simonbackx/simple-errors';
|
|
3
3
|
import { EmailVerificationCode, MFARecoveryCode, MFATOTP, MFAToken, Organization, OrganizationFactory, PasswordToken, Token, User, UserFactory, WebauthnCredential } from '@stamhoofd/models';
|
|
4
|
-
import { PermissionLevel, Permissions, Token as TokenStruct } from '@stamhoofd/structures';
|
|
4
|
+
import { NewUser, PermissionLevel, Permissions, Token as TokenStruct } from '@stamhoofd/structures';
|
|
5
5
|
import { TestUtils } from '@stamhoofd/test-utils';
|
|
6
6
|
import crypto from 'crypto';
|
|
7
7
|
import { authenticator } from 'otplib';
|
|
@@ -16,6 +16,7 @@ import { DeletePasskeyEndpoint } from './DeletePasskeyEndpoint.js';
|
|
|
16
16
|
import { DeleteTOTPEndpoint } from './DeleteTOTPEndpoint.js';
|
|
17
17
|
import { GetMFAChallengeEndpoint } from './GetMFAChallengeEndpoint.js';
|
|
18
18
|
import { GetMFAStatusEndpoint } from './GetMFAStatusEndpoint.js';
|
|
19
|
+
import { PatchUserEndpoint } from './PatchUserEndpoint.js';
|
|
19
20
|
import { RegisterPasskeyOptionsEndpoint } from './RegisterPasskeyOptionsEndpoint.js';
|
|
20
21
|
import { SetupTOTPEndpoint } from './SetupTOTPEndpoint.js';
|
|
21
22
|
import { VerifyEmailEndpoint } from './VerifyEmailEndpoint.js';
|
|
@@ -508,6 +509,80 @@ describe('MFA security', () => {
|
|
|
508
509
|
});
|
|
509
510
|
});
|
|
510
511
|
|
|
512
|
+
// -----------------------------------------------------------------------
|
|
513
|
+
// Absorbing someone else's account by taking over their email address
|
|
514
|
+
// -----------------------------------------------------------------------
|
|
515
|
+
describe('merging accounts', () => {
|
|
516
|
+
/**
|
|
517
|
+
* Verifying an email address that already belongs to another account merges that
|
|
518
|
+
* account into yours. Reading the victim's mailbox is a single primary credential,
|
|
519
|
+
* exactly the one a second factor exists to back up, so it must not be enough to
|
|
520
|
+
* pull an account that has a second factor (and its permissions) into an account
|
|
521
|
+
* that does not.
|
|
522
|
+
*/
|
|
523
|
+
async function requestEmailChange(user: User, token: Token, newEmail: string, organization: Organization): Promise<EmailVerificationCode> {
|
|
524
|
+
const request = Request.patch({
|
|
525
|
+
path: '/user/' + user.id,
|
|
526
|
+
host: organization.getApiHost(),
|
|
527
|
+
headers: { authorization: 'Bearer ' + token.accessToken },
|
|
528
|
+
body: NewUser.patch({ id: user.id, email: newEmail }),
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
const err = await captureError(testServer.test(new PatchUserEndpoint(), request));
|
|
532
|
+
expect(err.code).toBe('verify_email');
|
|
533
|
+
|
|
534
|
+
const verificationToken = (err.meta as { token: string }).token;
|
|
535
|
+
const code = await EmailVerificationCode.select().where('token', verificationToken).first(true);
|
|
536
|
+
expect(code.email).toBe(newEmail);
|
|
537
|
+
return code;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
test('taking over the email address of an account with a second factor is refused', async () => {
|
|
541
|
+
const organization = await new OrganizationFactory({}).create();
|
|
542
|
+
const victim = await new UserFactory({ organization, password, permissions: Permissions.create({ level: PermissionLevel.Full }) }).create();
|
|
543
|
+
const victimTotp = await addConfirmedTOTP(victim);
|
|
544
|
+
|
|
545
|
+
const attacker = await new UserFactory({ organization, password }).create();
|
|
546
|
+
const attackerToken = await freshToken(attacker);
|
|
547
|
+
|
|
548
|
+
const code = await requestEmailChange(attacker, attackerToken, victim.email, organization);
|
|
549
|
+
|
|
550
|
+
const err = await captureError(testServer.test(new VerifyEmailEndpoint(), bearer(Request.buildJson('POST', '/verify-email', organization.getApiHost(), { token: code.token, code: code.code }), attackerToken)));
|
|
551
|
+
expect(err.code).toBe('email_in_use');
|
|
552
|
+
|
|
553
|
+
// The victim still owns their account, their permissions and their factor.
|
|
554
|
+
const storedVictim = await User.getByID(victim.id);
|
|
555
|
+
expect(storedVictim).toBeDefined();
|
|
556
|
+
expect(storedVictim!.email).toBe(victim.email);
|
|
557
|
+
expect(await MFATOTP.getByID(victimTotp.id)).toBeDefined();
|
|
558
|
+
|
|
559
|
+
const storedAttacker = await User.getByID(attacker.id);
|
|
560
|
+
expect(storedAttacker!.email).toBe(attacker.email);
|
|
561
|
+
expect(storedAttacker!.permissions).toBeNull();
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
test('an account without a second factor can still be merged', async () => {
|
|
565
|
+
// Merging is a real feature for people who signed up twice. Without a factor,
|
|
566
|
+
// whoever reads the mailbox could take that account over with a password reset
|
|
567
|
+
// anyway, so nothing is bypassed here.
|
|
568
|
+
const organization = await new OrganizationFactory({}).create();
|
|
569
|
+
const other = await new UserFactory({ organization, password, permissions: Permissions.create({ level: PermissionLevel.Full }) }).create();
|
|
570
|
+
|
|
571
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
572
|
+
const token = await freshToken(user);
|
|
573
|
+
|
|
574
|
+
const code = await requestEmailChange(user, token, other.email, organization);
|
|
575
|
+
|
|
576
|
+
const response = await testServer.test(new VerifyEmailEndpoint(), bearer(Request.buildJson('POST', '/verify-email', organization.getApiHost(), { token: code.token, code: code.code }), token));
|
|
577
|
+
expect(response.body).toBeInstanceOf(TokenStruct);
|
|
578
|
+
|
|
579
|
+
expect(await User.getByID(other.id)).toBeUndefined();
|
|
580
|
+
const stored = await User.getByID(user.id);
|
|
581
|
+
expect(stored!.email).toBe(other.email);
|
|
582
|
+
expect(stored!.permissions).not.toBeNull();
|
|
583
|
+
});
|
|
584
|
+
});
|
|
585
|
+
|
|
511
586
|
// -----------------------------------------------------------------------
|
|
512
587
|
// Changing the factors of an account ends the sessions the user is not on
|
|
513
588
|
// -----------------------------------------------------------------------
|
|
@@ -82,6 +82,20 @@ export class VerifyEmailEndpoint extends Endpoint<Params, Query, Body, ResponseB
|
|
|
82
82
|
const other = await User.getForAuthentication(user.organizationId, code.email, { allowWithoutAccount: true });
|
|
83
83
|
|
|
84
84
|
if (other) {
|
|
85
|
+
// Merging absorbs the other account (its permissions included) and then
|
|
86
|
+
// deletes it, second factor and all. Reading the mailbox is exactly the
|
|
87
|
+
// single credential a second factor exists to back up, so an account that
|
|
88
|
+
// has one may not be taken over this way: nothing in this request proves
|
|
89
|
+
// the caller can pass it.
|
|
90
|
+
if (await TwoFactorHelper.userHasFactors(other.id)) {
|
|
91
|
+
throw new SimpleError({
|
|
92
|
+
code: 'email_in_use',
|
|
93
|
+
message: 'This e-mail is already in use by an account with two-factor authentication',
|
|
94
|
+
human: $t('%Zis'),
|
|
95
|
+
statusCode: 400,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
85
99
|
// Delete the other user, but merge data
|
|
86
100
|
await user.merge(other);
|
|
87
101
|
if (user.organizationId) {
|
package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemBreakdownEndpoint.test.ts
CHANGED
|
@@ -317,17 +317,17 @@ describe('Endpoint.GetBalanceItemBreakdownEndpoint', () => {
|
|
|
317
317
|
expect(response.status).toBe(200);
|
|
318
318
|
expect(response.body.bySettlement.map(g => ({ name: g.name.toString(), price: g.price }))).toEqual([
|
|
319
319
|
{ name: '1234567.0312.01', price: 40_00 },
|
|
320
|
-
{ name: $t('
|
|
320
|
+
{ name: $t('%1OL'), price: 30_00 },
|
|
321
321
|
// Only what was tried sits under the failed payment, the rest was never attempted
|
|
322
|
-
{ name: $t('
|
|
323
|
-
{ name: $t('
|
|
322
|
+
{ name: $t('%ZjW'), price: 20_00 },
|
|
323
|
+
{ name: $t('%ZjC'), price: 10_00 },
|
|
324
324
|
]);
|
|
325
325
|
|
|
326
326
|
// Every part of what was charged ends up in exactly one row
|
|
327
327
|
expect(response.body.bySettlement.reduce((total, g) => total + g.price, 0)).toBe(response.body.price);
|
|
328
328
|
|
|
329
329
|
// Running the rows through the database gives back the balance items they were added up from
|
|
330
|
-
for (const name of [$t('
|
|
330
|
+
for (const name of [$t('%ZjW'), $t('%ZjC')]) {
|
|
331
331
|
const row = response.body.bySettlement.find(g => g.name.toString() === name)!;
|
|
332
332
|
const exported = await getBreakdown({ organization, user, filter: row.selection!.listFilter });
|
|
333
333
|
expect(exported.body.balanceItemCount).toBe(1);
|
|
@@ -348,19 +348,19 @@ describe('Endpoint.GetBalanceItemBreakdownEndpoint', () => {
|
|
|
348
348
|
expect(response.status).toBe(200);
|
|
349
349
|
expect(response.body.bySettlement.map(g => ({ name: g.name.toString(), price: g.price }))).toEqual([
|
|
350
350
|
{ name: '1234567.0312.01', price: 50_00 },
|
|
351
|
-
{ name: $t('
|
|
352
|
-
{ name: $t('
|
|
351
|
+
{ name: $t('%10b'), price: -50_00 },
|
|
352
|
+
{ name: $t('%1Ni'), price: 25_00 },
|
|
353
353
|
]);
|
|
354
354
|
|
|
355
355
|
// A canceled item is not charged anymore, so it doesn't add to what is open
|
|
356
356
|
expect(response.body.bySettlement.reduce((total, g) => total + g.price, 0)).toBe(response.body.price);
|
|
357
357
|
|
|
358
358
|
// Each row selects exactly the balance items it was added up from
|
|
359
|
-
const refund = response.body.bySettlement.find(g => g.name.toString() === $t('
|
|
359
|
+
const refund = response.body.bySettlement.find(g => g.name.toString() === $t('%10b'))!;
|
|
360
360
|
const refunded = await getBreakdown({ organization, user, filter: refund.selection!.listFilter });
|
|
361
361
|
expect(refunded.body.balanceItemCount).toBe(1);
|
|
362
362
|
|
|
363
|
-
const open = response.body.bySettlement.find(g => g.name.toString() === $t('
|
|
363
|
+
const open = response.body.bySettlement.find(g => g.name.toString() === $t('%1Ni'))!;
|
|
364
364
|
const stillOpen = await getBreakdown({ organization, user, filter: open.selection!.listFilter });
|
|
365
365
|
expect(stillOpen.body.balanceItemCount).toBe(1);
|
|
366
366
|
expect(stillOpen.body.price).toBe(25_00);
|
|
@@ -381,13 +381,13 @@ describe('Endpoint.GetBalanceItemBreakdownEndpoint', () => {
|
|
|
381
381
|
|
|
382
382
|
expect(response.status).toBe(200);
|
|
383
383
|
expect(response.body.bySettlement.map(g => ({ name: g.name.toString(), price: g.price }))).toEqual([
|
|
384
|
-
{ name: $t('
|
|
384
|
+
{ name: $t('%ZjW'), price: 100_00 },
|
|
385
385
|
{ name: '1234567.0312.01', price: 40_00 },
|
|
386
|
-
{ name: $t('
|
|
386
|
+
{ name: $t('%1Ni'), price: 25_00 },
|
|
387
387
|
]);
|
|
388
388
|
|
|
389
389
|
// Both rows select exactly the balance items they were added up from
|
|
390
|
-
for (const [name, count] of [[$t('
|
|
390
|
+
for (const [name, count] of [[$t('%ZjW'), 1], [$t('%1Ni'), 1]] as [string, number][]) {
|
|
391
391
|
const row = response.body.bySettlement.find(g => g.name.toString() === name)!;
|
|
392
392
|
const exported = await getBreakdown({ organization, user, filter: row.selection!.listFilter });
|
|
393
393
|
expect(exported.body.balanceItemCount).toBe(count);
|
|
@@ -405,7 +405,7 @@ describe('Endpoint.GetBalanceItemBreakdownEndpoint', () => {
|
|
|
405
405
|
await payItem(organization, processing, { price: 25_00, status: PaymentStatus.Pending });
|
|
406
406
|
|
|
407
407
|
const all = await getBreakdown({ organization, user });
|
|
408
|
-
const row = all.body.bySettlement.find(g => g.name.toString() === $t('
|
|
408
|
+
const row = all.body.bySettlement.find(g => g.name.toString() === $t('%1OL'))!;
|
|
409
409
|
|
|
410
410
|
const narrowed = await getBreakdown({
|
|
411
411
|
organization,
|
|
@@ -367,7 +367,7 @@ describe('Endpoint.GetPaymentBreakdownEndpoint', () => {
|
|
|
367
367
|
|
|
368
368
|
expect(response.status).toBe(200);
|
|
369
369
|
expect(response.body.byArticle).toHaveLength(1);
|
|
370
|
-
expect(response.body.byArticle[0].name.toString()).toBe($t('
|
|
370
|
+
expect(response.body.byArticle[0].name.toString()).toBe($t('%Zjb'));
|
|
371
371
|
expect(response.body.byArticle[0].price).toBe(1_00);
|
|
372
372
|
});
|
|
373
373
|
|
|
@@ -391,7 +391,7 @@ describe('Endpoint.GetPaymentBreakdownEndpoint', () => {
|
|
|
391
391
|
const response = await getBreakdown({ organization, user });
|
|
392
392
|
|
|
393
393
|
expect(response.status).toBe(200);
|
|
394
|
-
expect(response.body.byArticle.map(g => g.name.toString())).toEqual([$t('
|
|
394
|
+
expect(response.body.byArticle.map(g => g.name.toString())).toEqual([$t('%Zik')]);
|
|
395
395
|
});
|
|
396
396
|
});
|
|
397
397
|
|
|
@@ -652,8 +652,8 @@ describe('Endpoint.GetPaymentBreakdownEndpoint', () => {
|
|
|
652
652
|
expect(response.status).toBe(200);
|
|
653
653
|
expect(response.body.bySettlement.map(g => ({ name: g.name.toString(), price: g.price, count: g.count }))).toEqual([
|
|
654
654
|
{ name: '1234567.0312.01', price: 50_00, count: 2 },
|
|
655
|
-
{ name: $t('
|
|
656
|
-
{ name: $t('
|
|
655
|
+
{ name: $t('%Zjn'), price: 30_00, count: 1 },
|
|
656
|
+
{ name: $t('%ZjN'), price: 20_00, count: 1 },
|
|
657
657
|
]);
|
|
658
658
|
});
|
|
659
659
|
|
|
@@ -717,7 +717,7 @@ describe('Endpoint.GetPaymentBreakdownEndpoint', () => {
|
|
|
717
717
|
await createPayment(organization, { items: [[item, 20_00]], method: PaymentMethod.PointOfSale });
|
|
718
718
|
|
|
719
719
|
const all = await getBreakdown({ organization, user });
|
|
720
|
-
const pending = all.body.bySettlement.find(g => g.name.toString() === $t('
|
|
720
|
+
const pending = all.body.bySettlement.find(g => g.name.toString() === $t('%Zjn'))!;
|
|
721
721
|
|
|
722
722
|
const exported = await getBreakdown({ organization, user, filter: pending.selection!.filter });
|
|
723
723
|
expect(exported.body.price).toBe(30_00);
|
|
@@ -735,7 +735,7 @@ describe('Endpoint.GetPaymentBreakdownEndpoint', () => {
|
|
|
735
735
|
await createPayment(organization, { items: [[item, 10_00]], iban: 'BE68539007547034' });
|
|
736
736
|
|
|
737
737
|
const all = await getBreakdown({ organization, user });
|
|
738
|
-
const offline = all.body.bySettlement.find(g => g.name.toString() === $t('
|
|
738
|
+
const offline = all.body.bySettlement.find(g => g.name.toString() === $t('%ZjN'))!;
|
|
739
739
|
|
|
740
740
|
const exported = await getBreakdown({ organization, user, filter: offline.selection!.filter });
|
|
741
741
|
expect(exported.body.price).toBe(30_00);
|
|
@@ -756,9 +756,9 @@ describe('Endpoint.GetPaymentBreakdownEndpoint', () => {
|
|
|
756
756
|
const all = await getBreakdown({ organization, user });
|
|
757
757
|
|
|
758
758
|
expect(all.body.bySettlement.map(g => ({ name: g.name.toString(), price: g.price }))).toEqual([
|
|
759
|
-
{ name: $t('
|
|
759
|
+
{ name: $t('%Zjn'), price: 40_00 },
|
|
760
760
|
{ name: getPaymentProviderName(PaymentProvider.Buckaroo), price: 25_00 },
|
|
761
|
-
{ name: $t('
|
|
761
|
+
{ name: $t('%ZjN'), price: 20_00 },
|
|
762
762
|
{ name: getPaymentProviderName(PaymentProvider.Payconiq), price: 15_00 },
|
|
763
763
|
]);
|
|
764
764
|
|
|
@@ -812,7 +812,7 @@ describe('Endpoint.GetPaymentBreakdownEndpoint', () => {
|
|
|
812
812
|
|
|
813
813
|
expect(response.body.byCategory.map(g => ({ name: g.name.toString(), price: g.price }))).toEqual([
|
|
814
814
|
{ name: 'Kapoenen', price: 14_6652 },
|
|
815
|
-
{ name: $t('
|
|
815
|
+
{ name: $t('%1b6'), price: 48 },
|
|
816
816
|
]);
|
|
817
817
|
|
|
818
818
|
// Everything the payment is worth is accounted for
|
|
@@ -870,12 +870,12 @@ describe('Endpoint.GetPaymentBreakdownEndpoint', () => {
|
|
|
870
870
|
|
|
871
871
|
expect(response.status).toBe(200);
|
|
872
872
|
expect(response.body.bySettlement.map(g => ({ name: g.name.toString(), price: g.price }))).toEqual([
|
|
873
|
-
{ name: $t('
|
|
873
|
+
{ name: $t('%ZjN'), price: 40_00 },
|
|
874
874
|
{ name: PaymentMethodHelper.getNameCapitalized(PaymentMethod.AccountDeductions), price: 10_00 },
|
|
875
875
|
]);
|
|
876
876
|
|
|
877
877
|
// Running the rows through the database keeps them apart
|
|
878
|
-
for (const [name, price] of [[$t('
|
|
878
|
+
for (const [name, price] of [[$t('%ZjN'), 40_00], [PaymentMethodHelper.getNameCapitalized(PaymentMethod.AccountDeductions), 10_00]] as [string, number][]) {
|
|
879
879
|
const row = response.body.bySettlement.find(g => g.name.toString() === name)!;
|
|
880
880
|
const exported = await getBreakdown({ organization, user, filter: row.selection!.listFilter });
|
|
881
881
|
expect(exported.body.price).toBe(price);
|
|
@@ -212,7 +212,7 @@ export class TwoFactorHelper {
|
|
|
212
212
|
throw new SimpleError({
|
|
213
213
|
code: 'require_email_confirmation',
|
|
214
214
|
message: 'Email confirmation required before two-factor authentication setup',
|
|
215
|
-
human: $t('
|
|
215
|
+
human: $t('%Zjm', { days: INACTIVE_ADMIN_ENROLLMENT_DAYS.toString() }),
|
|
216
216
|
statusCode: 403,
|
|
217
217
|
});
|
|
218
218
|
}
|
|
@@ -52,7 +52,7 @@ export async function streamForBreakdown<T>(options: {
|
|
|
52
52
|
throw new SimpleError({
|
|
53
53
|
code: 'breakdown_pending',
|
|
54
54
|
message: 'A breakdown is already running for this user',
|
|
55
|
-
human: $t('
|
|
55
|
+
human: $t('%Zj3'),
|
|
56
56
|
statusCode: 429,
|
|
57
57
|
});
|
|
58
58
|
}
|
|
@@ -96,7 +96,7 @@ function assertBreakdownSize(count: number) {
|
|
|
96
96
|
throw new SimpleError({
|
|
97
97
|
code: 'too_many_objects',
|
|
98
98
|
message: 'Too many objects to break down',
|
|
99
|
-
human: $t('
|
|
99
|
+
human: $t('%Ziw', {
|
|
100
100
|
limit: Formatter.integer(MAX_BREAKDOWN_OBJECTS),
|
|
101
101
|
}),
|
|
102
102
|
statusCode: 400,
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { SimpleError } from '@simonbackx/simple-errors';
|
|
2
2
|
import { Document, DocumentTemplateFactory, Organization } from '@stamhoofd/models';
|
|
3
|
-
import {
|
|
3
|
+
import { render } from '@stamhoofd/models/helpers/Handlebars.js';
|
|
4
|
+
import type { RecordAnswer } from '@stamhoofd/structures';
|
|
5
|
+
import { Address, DocumentData, DocumentStatus, File, Image, OrganizationMetaData, PlatformConfig, Platform as PlatformStruct, RecordCheckboxAnswer, RecordDateAnswer, RecordPriceAnswer, RecordSettings, RecordTextAnswer, RecordType } from '@stamhoofd/structures';
|
|
4
6
|
import { Country } from '@stamhoofd/types/Country';
|
|
7
|
+
import { Formatter } from '@stamhoofd/utility';
|
|
5
8
|
import { DocumentRenderService } from './DocumentRenderService.js';
|
|
6
9
|
|
|
7
10
|
function createImage(id: string) {
|
|
@@ -66,6 +69,49 @@ function createInvalidFieldAnswers() {
|
|
|
66
69
|
|
|
67
70
|
const xmlExport = '<documents>{{#each documents}}<document>{{{this.number}}}</document>{{/each}}</documents>';
|
|
68
71
|
|
|
72
|
+
/**
|
|
73
|
+
* The day price row of the participation template (templates/participation.html in the dashboard).
|
|
74
|
+
*/
|
|
75
|
+
const dayPriceTemplate = '{{#if (and registration.showDayPrice (coalesce registration.price registration.priceOriginal 0)) }}'
|
|
76
|
+
+ 'Bedrag per dag: {{ formatPrice (div (coalesce registration.price registration.priceOriginal 0) (coalesce registration.days (days registration.startDate registration.endDate))) round=true }}'
|
|
77
|
+
+ '{{/if}}';
|
|
78
|
+
|
|
79
|
+
function createParticipationDocument(answers: { showDayPrice: boolean; price?: number | null; priceOriginal?: number | null; startDate?: Date; endDate?: Date }) {
|
|
80
|
+
const settingsFor = (id: string, type: RecordType) => RecordSettings.create({ id, type });
|
|
81
|
+
|
|
82
|
+
const fieldAnswers = new Map<string, RecordAnswer>([
|
|
83
|
+
['registration.showDayPrice', RecordCheckboxAnswer.create({
|
|
84
|
+
settings: settingsFor('registration.showDayPrice', RecordType.Checkbox),
|
|
85
|
+
selected: answers.showDayPrice,
|
|
86
|
+
})],
|
|
87
|
+
['registration.price', RecordPriceAnswer.create({
|
|
88
|
+
settings: settingsFor('registration.price', RecordType.Price),
|
|
89
|
+
value: answers.price ?? null,
|
|
90
|
+
})],
|
|
91
|
+
['registration.priceOriginal', RecordPriceAnswer.create({
|
|
92
|
+
settings: settingsFor('registration.priceOriginal', RecordType.Price),
|
|
93
|
+
value: answers.priceOriginal ?? null,
|
|
94
|
+
})],
|
|
95
|
+
['registration.startDate', RecordDateAnswer.create({
|
|
96
|
+
settings: settingsFor('registration.startDate', RecordType.Date),
|
|
97
|
+
dateValue: answers.startDate ?? new Date(2024, 6, 1),
|
|
98
|
+
})],
|
|
99
|
+
['registration.endDate', RecordDateAnswer.create({
|
|
100
|
+
settings: settingsFor('registration.endDate', RecordType.Date),
|
|
101
|
+
dateValue: answers.endDate ?? new Date(2024, 6, 7),
|
|
102
|
+
})],
|
|
103
|
+
]);
|
|
104
|
+
|
|
105
|
+
const document = createDocument();
|
|
106
|
+
document.data.fieldAnswers = fieldAnswers;
|
|
107
|
+
return document;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function renderDayPrice(answers: Parameters<typeof createParticipationDocument>[0]) {
|
|
111
|
+
const context = DocumentRenderService.buildDocumentContext(createParticipationDocument(answers), createOrganization(), createPlatform({}));
|
|
112
|
+
return await render(dayPriceTemplate, context);
|
|
113
|
+
}
|
|
114
|
+
|
|
69
115
|
/**
|
|
70
116
|
* Builds a locked template: a locked template makes buildAll return the documents unchanged, so the
|
|
71
117
|
* numbers set up here are exactly what the renumbering logic gets to see.
|
|
@@ -150,6 +196,31 @@ describe('DocumentRenderService', () => {
|
|
|
150
196
|
});
|
|
151
197
|
});
|
|
152
198
|
|
|
199
|
+
describe('day price', () => {
|
|
200
|
+
// 1 July until 7 July are 7 days, so €25 is rounded to €3,57 per day
|
|
201
|
+
const dayPriceOf25 = 'Bedrag per dag: ' + Formatter.price(3_5700);
|
|
202
|
+
|
|
203
|
+
test('It divides the price by the amount of days, both start and end date included', async () => {
|
|
204
|
+
await expect(renderDayPrice({ showDayPrice: true, price: 25_0000 })).resolves.toBe(dayPriceOf25);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('It uses the original price when the price itself is hidden on the document', async () => {
|
|
208
|
+
await expect(renderDayPrice({ showDayPrice: true, price: null, priceOriginal: 25_0000 })).resolves.toBe(dayPriceOf25);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('It prefers the price of the document over the original price', async () => {
|
|
212
|
+
await expect(renderDayPrice({ showDayPrice: true, price: 14_0000, priceOriginal: 25_0000 })).resolves.toBe('Bedrag per dag: ' + Formatter.price(2_0000));
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test('It is left out when the checkbox is not selected', async () => {
|
|
216
|
+
await expect(renderDayPrice({ showDayPrice: false, price: 25_0000 })).resolves.toBe('');
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test('It is left out when no price is known', async () => {
|
|
220
|
+
await expect(renderDayPrice({ showDayPrice: true, price: null, priceOriginal: null })).resolves.toBe('');
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
153
224
|
describe('getRenderedHtml', () => {
|
|
154
225
|
test('It returns null and logs when building the context fails', async () => {
|
|
155
226
|
const template = await new DocumentTemplateFactory({ groups: [] }).create();
|
|
@@ -517,6 +517,14 @@ export class PaymentService {
|
|
|
517
517
|
}
|
|
518
518
|
}
|
|
519
519
|
|
|
520
|
+
// DirectDebit expires after 3 weeks
|
|
521
|
+
if ((status === PaymentStatus.Pending || status === PaymentStatus.Created) && payment.method === PaymentMethod.DirectDebit) {
|
|
522
|
+
// If payment is not succeeded after one day, mark as failed
|
|
523
|
+
if (payment.createdAt < new Date(new Date().getTime() - 60 * 1000 * 60 * 24 * 7 * 3)) {
|
|
524
|
+
return true;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
520
528
|
if (STAMHOOFD.environment === 'development') {
|
|
521
529
|
// In development, we expire all direct debits and other paymetns after 1 hour, because they need manual changes
|
|
522
530
|
// otherwise they will remain stuck in the dev environment, poluting the UI
|
|
@@ -30,13 +30,17 @@ export const orderFilterCompilers: SQLFilterDefinitions = {
|
|
|
30
30
|
nullable: false,
|
|
31
31
|
}),
|
|
32
32
|
timeSlotEndTime: createColumnFilter({
|
|
33
|
+
// Stored as a number (minutes since midnight), so it must be typed as a JSON number to allow
|
|
34
|
+
// numeric comparisons and to match the in-memory filter.
|
|
33
35
|
expression: SQL.jsonExtract(SQL.column('data'), '$.value.timeSlot.endTime'),
|
|
34
|
-
type: SQLValueType.
|
|
36
|
+
type: SQLValueType.JSONNumber,
|
|
35
37
|
nullable: true,
|
|
36
38
|
}),
|
|
37
39
|
timeSlotStartTime: createColumnFilter({
|
|
40
|
+
// Stored as a number (minutes since midnight), so it must be typed as a JSON number to allow
|
|
41
|
+
// numeric comparisons and to match the in-memory filter.
|
|
38
42
|
expression: SQL.jsonExtract(SQL.column('data'), '$.value.timeSlot.startTime'),
|
|
39
|
-
type: SQLValueType.
|
|
43
|
+
type: SQLValueType.JSONNumber,
|
|
40
44
|
nullable: true,
|
|
41
45
|
}),
|
|
42
46
|
createdAt: createColumnFilter({
|
|
@@ -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
|
+
});
|