@stamhoofd/backend 2.133.0 → 2.135.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.
Files changed (35) hide show
  1. package/package.json +18 -18
  2. package/src/crons/invoices.ts +5 -3
  3. package/src/endpoints/auth/VerifyEmailEndpoint.test.ts +154 -0
  4. package/src/endpoints/auth/VerifyEmailEndpoint.ts +4 -0
  5. package/src/endpoints/global/members/GetMembersEndpoint.test.ts +224 -2
  6. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.test.ts +190 -2
  7. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +72 -14
  8. package/src/endpoints/global/registration/RegisterMembersEndpoint.test.ts +150 -0
  9. package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +4 -3
  10. package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemsEndpoint.test.ts +183 -0
  11. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +2 -0
  12. package/src/endpoints/organization/dashboard/organization/SetUitpasClientCredentialsEndpoint.ts +1 -8
  13. package/src/endpoints/organization/dashboard/payments/GetPaymentsEndpoint.test.ts +158 -0
  14. package/src/endpoints/organization/dashboard/webshops/PatchWebshopEndpoint.test.ts +49 -0
  15. package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.test.ts +71 -0
  16. package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +4 -0
  17. package/src/endpoints/organization/webshops/GetWebshopEndpoint.test.ts +11 -0
  18. package/src/endpoints/organization/webshops/OrderConfirmationEmailLanguage.test.ts +70 -0
  19. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.test.ts +68 -1
  20. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +8 -0
  21. package/src/helpers/GlobalHelper.ts +1 -1
  22. package/src/helpers/MembershipCharger.ts +2 -1
  23. package/src/helpers/StripeInvoicer.ts +13 -2
  24. package/src/helpers/UitpasTokenRepository.ts +12 -11
  25. package/src/seeds/1783097277-update-member-last-registered.sql +6 -0
  26. package/src/seeds/1783939632-mark-zero-price-payments-succeeded.ts +90 -0
  27. package/src/services/PaymentService.ts +33 -8
  28. package/src/services/PlatformMembershipService.ts +2 -1
  29. package/src/services/RegistrationService.ts +5 -0
  30. package/src/services/uitpas/UitpasService.ts +3 -4
  31. package/src/services/uitpas/checkPermissionsFor.ts +1 -1
  32. package/src/sql-filters/balance-item-payments.ts +4 -56
  33. package/src/sql-filters/balance-items.ts +69 -10
  34. package/src/sql-filters/members.ts +5 -0
  35. package/src/sql-sorters/members.ts +12 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stamhoofd/backend",
3
- "version": "2.133.0",
3
+ "version": "2.135.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "exports": {
@@ -53,24 +53,24 @@
53
53
  "@bwip-js/node": "4.11.1",
54
54
  "@mollie/api-client": "4.5.0",
55
55
  "@simonbackx/simple-database": "1.37.1",
56
- "@simonbackx/simple-encoding": "2.26.10",
56
+ "@simonbackx/simple-encoding": "2.26.11",
57
57
  "@simonbackx/simple-endpoints": "1.21.1",
58
58
  "@simonbackx/simple-errors": "1.5.0",
59
59
  "@simonbackx/simple-logging": "1.0.1",
60
- "@stamhoofd/backend-env": "2.133.0",
61
- "@stamhoofd/backend-i18n": "2.133.0",
62
- "@stamhoofd/backend-middleware": "2.133.0",
63
- "@stamhoofd/crons": "2.133.0",
64
- "@stamhoofd/email": "2.133.0",
65
- "@stamhoofd/excel-writer": "2.133.0",
66
- "@stamhoofd/logging": "2.133.0",
67
- "@stamhoofd/models": "2.133.0",
68
- "@stamhoofd/object-differ": "2.133.0",
69
- "@stamhoofd/queues": "2.133.0",
70
- "@stamhoofd/sql": "2.133.0",
71
- "@stamhoofd/structures": "2.133.0",
72
- "@stamhoofd/types": "2.133.0",
73
- "@stamhoofd/utility": "2.133.0",
60
+ "@stamhoofd/backend-env": "2.135.0",
61
+ "@stamhoofd/backend-i18n": "2.135.0",
62
+ "@stamhoofd/backend-middleware": "2.135.0",
63
+ "@stamhoofd/crons": "2.135.0",
64
+ "@stamhoofd/email": "2.135.0",
65
+ "@stamhoofd/excel-writer": "2.135.0",
66
+ "@stamhoofd/logging": "2.135.0",
67
+ "@stamhoofd/models": "2.135.0",
68
+ "@stamhoofd/object-differ": "2.135.0",
69
+ "@stamhoofd/queues": "2.135.0",
70
+ "@stamhoofd/sql": "2.135.0",
71
+ "@stamhoofd/structures": "2.135.0",
72
+ "@stamhoofd/types": "2.135.0",
73
+ "@stamhoofd/utility": "2.135.0",
74
74
  "archiver": "7.0.1",
75
75
  "axios": "1.16.0",
76
76
  "base-x": "3.0.11",
@@ -91,7 +91,7 @@
91
91
  "stripe": "16.12.0"
92
92
  },
93
93
  "devDependencies": {
94
- "@stamhoofd/test-utils": "2.133.0",
94
+ "@stamhoofd/test-utils": "2.135.0",
95
95
  "@types/cookie": "0.6.0",
96
96
  "@types/luxon": "3.7.1",
97
97
  "@types/mailparser": "3.4.6",
@@ -107,5 +107,5 @@
107
107
  "publishConfig": {
108
108
  "access": "public"
109
109
  },
110
- "gitHead": "65d5f0a31f87b8e3b7a23349f08cf7943ef15bda"
110
+ "gitHead": "df20ecb0e4b10af877d2e37e92e39100afe342e8"
111
111
  }
@@ -52,10 +52,12 @@ async function createInvoicesFor(organization: Organization) {
52
52
  const today = Formatter.luxon();
53
53
  const startDate = today.day <= 15 ? today.minus({ month: 2 }).startOf('month') : today.minus({ month: 1 }).startOf('month');
54
54
 
55
- // Don't invoice below 10 euro - unless we reached the timeout date for invoices (end of month + 15 days - 5 days margin)
56
- const invoiceLimit = STAMHOOFD.environment === 'development' ? 0 : 10_0000;
55
+ // Don't invoice below 4 euro - unless we reached the timeout date for invoices (end of month + 15 days - 5 days margin) OR + 15 day offset
56
+ const invoiceLimit = STAMHOOFD.environment === 'development' ? 0 : 4_0000;
57
57
  function getPaymentTimeoutDate(p: Payment) {
58
- return Formatter.luxon(p.paidAt ?? p.createdAt).plus({ month: 1 }).set({ day: 10 }).startOf('day').toJSDate();
58
+ const a = Formatter.luxon(p.paidAt ?? p.createdAt).plus({ month: 1 }).set({ day: 10 }).startOf('day').toJSDate();
59
+ const b = Formatter.luxon(p.paidAt ?? p.createdAt).plus({ days: 15 }).startOf('day').toJSDate();
60
+ return new Date(Math.min(a.getTime(), b.getTime()));
59
61
  }
60
62
 
61
63
  console.log('Fetching all payments between ' + Formatter.dateTime(startDate.toJSDate()) + ' and now for ' + organization.name);
@@ -0,0 +1,154 @@
1
+ import { Request } from '@simonbackx/simple-endpoints';
2
+ import { AuditLog, EmailVerificationCode, OrganizationFactory, Token, User, UserFactory } from '@stamhoofd/models';
3
+ import type { Organization } from '@stamhoofd/models';
4
+ import { AuditLogType, PermissionLevel, PermissionRole, Permissions, Token as TokenStruct } from '@stamhoofd/structures';
5
+ import { TestUtils } from '@stamhoofd/test-utils';
6
+
7
+ import { testServer } from '../../../tests/helpers/TestServer.js';
8
+ import { VerifyEmailEndpoint } from './VerifyEmailEndpoint.js';
9
+
10
+ describe('Endpoint.VerifyEmail', () => {
11
+ const endpoint = new VerifyEmailEndpoint();
12
+
13
+ beforeEach(() => {
14
+ TestUtils.setEnvironment('userMode', 'organization');
15
+ });
16
+
17
+ /**
18
+ * Create a user with permissions for the given organization.
19
+ */
20
+ async function createUserWithPermissions(organization: Organization, email: string, permissions?: Permissions) {
21
+ return await new UserFactory({
22
+ organization,
23
+ email,
24
+ permissions,
25
+ }).create();
26
+ }
27
+
28
+ /**
29
+ * Build a verification code for the given user that will change the user's email to `newEmail`
30
+ * on verification, and run the endpoint with it.
31
+ */
32
+ async function verifyEmail(organization: Organization, user: User, newEmail: string) {
33
+ const code = await EmailVerificationCode.createFor(user, newEmail);
34
+
35
+ const request = Request.buildJson('POST', '/verify-email', organization.getApiHost(), {
36
+ token: code.token,
37
+ code: code.code,
38
+ });
39
+
40
+ return await testServer.test(endpoint, request);
41
+ }
42
+
43
+ test('merges the permissions of the deleted user into the kept user', async () => {
44
+ const organization = await new OrganizationFactory({}).create();
45
+
46
+ // The user that keeps existing and changes its email to the other user's email
47
+ const keptUser = await createUserWithPermissions(organization, 'kept@example.com', Permissions.create({
48
+ roles: [PermissionRole.create({ id: 'role-a', name: 'Role A' })],
49
+ }));
50
+
51
+ // The user that will be found by its email and merged into (and deleted)
52
+ const otherUser = await createUserWithPermissions(organization, 'other@example.com', Permissions.create({
53
+ level: PermissionLevel.Full,
54
+ roles: [PermissionRole.create({ id: 'role-b', name: 'Role B' })],
55
+ }));
56
+
57
+ const response = await verifyEmail(organization, keptUser, otherUser.email);
58
+ expect(response.status).toBe(200);
59
+
60
+ // The other user is deleted
61
+ expect(await User.getByID(otherUser.id)).toBeUndefined();
62
+
63
+ // The kept user now holds both users' permissions for the organization
64
+ const refreshed = await User.getByID(keptUser.id);
65
+ expect(refreshed).toBeDefined();
66
+ expect(refreshed!.email).toBe('other@example.com');
67
+
68
+ const merged = refreshed!.permissions!.organizationPermissions.get(organization.id);
69
+ expect(merged).toBeDefined();
70
+ expect(merged!.level).toBe(PermissionLevel.Full);
71
+ expect(merged!.roles.map(r => r.id).sort()).toEqual(['role-a', 'role-b']);
72
+ });
73
+
74
+ test('adopts the permissions of the deleted user when the kept user has none', async () => {
75
+ const organization = await new OrganizationFactory({}).create();
76
+
77
+ // The kept user has no permissions
78
+ const keptUser = await createUserWithPermissions(organization, 'kept@example.com');
79
+ expect(keptUser.permissions).toBeNull();
80
+
81
+ const otherUser = await createUserWithPermissions(organization, 'other@example.com', Permissions.create({
82
+ roles: [PermissionRole.create({ id: 'role-b', name: 'Role B' })],
83
+ }));
84
+
85
+ const response = await verifyEmail(organization, keptUser, otherUser.email);
86
+ expect(response.status).toBe(200);
87
+
88
+ expect(await User.getByID(otherUser.id)).toBeUndefined();
89
+
90
+ const refreshed = await User.getByID(keptUser.id);
91
+ const merged = refreshed!.permissions?.organizationPermissions.get(organization.id);
92
+ expect(merged).toBeDefined();
93
+ expect(merged!.roles.map(r => r.id)).toEqual(['role-b']);
94
+ });
95
+
96
+ test('reassigns audit logs of the deleted user to the kept user', async () => {
97
+ const organization = await new OrganizationFactory({}).create();
98
+
99
+ const keptUser = await createUserWithPermissions(organization, 'kept@example.com');
100
+ const otherUser = await createUserWithPermissions(organization, 'other@example.com');
101
+
102
+ // An audit log performed by the user that will be deleted
103
+ const auditLog = new AuditLog();
104
+ auditLog.type = AuditLogType.Unknown;
105
+ auditLog.userId = otherUser.id;
106
+ auditLog.organizationId = organization.id;
107
+ auditLog.description = 'Performed by the other user';
108
+ await auditLog.save();
109
+
110
+ const response = await verifyEmail(organization, keptUser, otherUser.email);
111
+ expect(response.status).toBe(200);
112
+
113
+ expect(await User.getByID(otherUser.id)).toBeUndefined();
114
+
115
+ // The audit log is now attributed to the kept user
116
+ const refreshedLog = await AuditLog.getByID(auditLog.id);
117
+ expect(refreshedLog).toBeDefined();
118
+ expect(refreshedLog!.userId).toBe(keptUser.id);
119
+ });
120
+
121
+ test('verifies the email and returns a valid token when merging without throwing', async () => {
122
+ const organization = await new OrganizationFactory({}).create();
123
+
124
+ const keptUser = await createUserWithPermissions(organization, 'kept@example.com', Permissions.create({
125
+ roles: [PermissionRole.create({ id: 'role-a', name: 'Role A' })],
126
+ }));
127
+ const otherUser = await createUserWithPermissions(organization, 'other@example.com', Permissions.create({
128
+ roles: [PermissionRole.create({ id: 'role-b', name: 'Role B' })],
129
+ }));
130
+
131
+ // Also add an audit log to exercise all merge branches together
132
+ const auditLog = new AuditLog();
133
+ auditLog.type = AuditLogType.Unknown;
134
+ auditLog.userId = otherUser.id;
135
+ auditLog.organizationId = organization.id;
136
+ await auditLog.save();
137
+
138
+ const response = await verifyEmail(organization, keptUser, otherUser.email);
139
+
140
+ expect(response.body).toBeInstanceOf(TokenStruct);
141
+ if (!(response.body instanceof TokenStruct)) {
142
+ throw new Error('Expected TokenStruct');
143
+ }
144
+
145
+ // The returned token belongs to the kept user
146
+ const token = await Token.getByAccessToken(response.body.accessToken);
147
+ expect(token).toBeDefined();
148
+ expect(token!.user.id).toBe(keptUser.id);
149
+
150
+ const refreshed = await User.getByID(keptUser.id);
151
+ expect(refreshed!.verified).toBe(true);
152
+ expect(refreshed!.email).toBe('other@example.com');
153
+ });
154
+ });
@@ -6,6 +6,7 @@ import { EmailVerificationCode, Token, User } from '@stamhoofd/models';
6
6
  import { Token as TokenStruct, VerifyEmailRequest } from '@stamhoofd/structures';
7
7
 
8
8
  import { Context } from '../../helpers/Context.js';
9
+ import { BalanceItemService } from '../../services/BalanceItemService.js';
9
10
 
10
11
  type Params = Record<string, never>;
11
12
  type Query = undefined;
@@ -77,6 +78,9 @@ export class VerifyEmailEndpoint extends Endpoint<Params, Query, Body, ResponseB
77
78
  if (other) {
78
79
  // Delete the other user, but merge data
79
80
  await user.merge(other);
81
+ if (user.organizationId) {
82
+ BalanceItemService.scheduleUserUpdate(user.organizationId, user.id);
83
+ }
80
84
  }
81
85
 
82
86
  // change user email
@@ -1,8 +1,9 @@
1
1
  import type { Endpoint } from '@simonbackx/simple-endpoints';
2
2
  import { Request } from '@simonbackx/simple-endpoints';
3
- import type { RegistrationPeriod } from '@stamhoofd/models';
3
+ import type { MemberWithUsersRegistrationsAndGroups, RegistrationPeriod } from '@stamhoofd/models';
4
4
  import { EventFactory, GroupFactory, MemberFactory, OrganizationFactory, RecordCategoryFactory, RegistrationFactory, RegistrationPeriodFactory, Token, UserFactory } from '@stamhoofd/models';
5
- import { AccessRight, EventMeta, GroupType, LimitedFilteredRequest, NamedObject, PermissionLevel, PermissionRoleDetailed, Permissions, PermissionsResourceType, RecordAnswer, RecordTextAnswer, RecordType, ResourcePermissions } from '@stamhoofd/structures';
5
+ import type { SortList } from '@stamhoofd/structures';
6
+ import { AccessRight, EventMeta, GroupType, LimitedFilteredRequest, NamedObject, PermissionLevel, PermissionRoleDetailed, Permissions, PermissionsResourceType, RecordAnswer, RecordTextAnswer, RecordType, ResourcePermissions, SortItemDirection } from '@stamhoofd/structures';
6
7
  import { STExpect, TestUtils } from '@stamhoofd/test-utils';
7
8
  import { GetMembersEndpoint } from './GetMembersEndpoint.js';
8
9
  import { testServer } from '../../../../tests/helpers/TestServer.js';
@@ -1959,4 +1960,225 @@ describe('Endpoint.GetMembersEndpoint', () => {
1959
1960
  ]);
1960
1961
  });
1961
1962
  });
1963
+
1964
+ describe('Sorting', () => {
1965
+ // Deliberately not in chronological order, so a passing test cannot be explained by insertion order
1966
+ const january = new Date(Date.UTC(2023, 0, 10, 8, 30, 0));
1967
+ const february = new Date(Date.UTC(2023, 1, 1, 0, 0, 0));
1968
+ const march = new Date(Date.UTC(2023, 2, 5, 12, 0, 0));
1969
+ const may = new Date(Date.UTC(2023, 4, 15, 16, 45, 0));
1970
+ const june = new Date(Date.UTC(2023, 5, 20, 22, 15, 0));
1971
+
1972
+ /**
1973
+ * Creates one member per passed value, all registered in the same group of a new organization,
1974
+ * and returns an admin with full access to that organization.
1975
+ */
1976
+ async function setupMembers(lastRegisteredAtValues: (Date | null)[]) {
1977
+ const organization = await new OrganizationFactory({ period }).create();
1978
+
1979
+ const user = await new UserFactory({
1980
+ organization,
1981
+ permissions: Permissions.create({
1982
+ level: PermissionLevel.Full,
1983
+ }),
1984
+ }).create();
1985
+
1986
+ const token = await Token.createToken(user);
1987
+ const group = await new GroupFactory({ organization, period }).create();
1988
+
1989
+ const members: MemberWithUsersRegistrationsAndGroups[] = [];
1990
+
1991
+ for (const lastRegisteredAt of lastRegisteredAtValues) {
1992
+ const member = await new MemberFactory({}).create();
1993
+ await new RegistrationFactory({ member, group }).create();
1994
+
1995
+ // The registration factory does not maintain lastRegisteredAt (only RegistrationService does),
1996
+ // so we set it explicitly to get deterministic values - including legacy members that never got one.
1997
+ member.lastRegisteredAt = lastRegisteredAt;
1998
+ await member.save();
1999
+
2000
+ members.push(member);
2001
+ }
2002
+
2003
+ return { host: organization.getApiHost(), token, members };
2004
+ }
2005
+
2006
+ /**
2007
+ * Requests every page by following the 'next' request the endpoint returns, exactly like the frontend does.
2008
+ * That next request is built from the sorter's getValue, so this covers the full sort + pagination flow:
2009
+ * getValue -> page filter -> encoded in the query -> decoded -> compiled back to SQL.
2010
+ *
2011
+ * Returns the ids of all members across all pages, in the order they were received.
2012
+ */
2013
+ async function fetchAllPages({ host, token, sort, limit }: { host: string; token: Token; sort: SortList; limit: number }) {
2014
+ const ids: string[] = [];
2015
+ let query: LimitedFilteredRequest | undefined = new LimitedFilteredRequest({ sort, limit });
2016
+ let pages = 0;
2017
+
2018
+ while (query) {
2019
+ const request = Request.get({
2020
+ path: baseUrl,
2021
+ host,
2022
+ query,
2023
+ headers: {
2024
+ authorization: 'Bearer ' + token.accessToken,
2025
+ },
2026
+ });
2027
+
2028
+ const response = await testServer.test(endpoint, request);
2029
+ expect(response.status).toBe(200);
2030
+
2031
+ ids.push(...response.body.results.members.map(m => m.id));
2032
+ query = response.body.next;
2033
+ pages += 1;
2034
+
2035
+ if (pages > 10) {
2036
+ throw new Error('Pagination did not terminate');
2037
+ }
2038
+ }
2039
+
2040
+ return ids;
2041
+ }
2042
+
2043
+ test('Members are sorted by lastRegisteredAt ascending across all pages', async () => {
2044
+ const { host, token, members } = await setupMembers([march, january, june, february, may]);
2045
+ const [marchMember, januaryMember, juneMember, februaryMember, mayMember] = members;
2046
+
2047
+ // A limit lower than the total forces the endpoint to build a next page filter from getValue
2048
+ const ids = await fetchAllPages({
2049
+ host,
2050
+ token,
2051
+ sort: [{ key: 'lastRegisteredAt', order: SortItemDirection.ASC }],
2052
+ limit: 2,
2053
+ });
2054
+
2055
+ expect(ids).toEqual([
2056
+ januaryMember.id,
2057
+ februaryMember.id,
2058
+ marchMember.id,
2059
+ mayMember.id,
2060
+ juneMember.id,
2061
+ ]);
2062
+ });
2063
+
2064
+ test('Members are sorted by lastRegisteredAt descending across all pages', async () => {
2065
+ const { host, token, members } = await setupMembers([march, january, june, february, may]);
2066
+ const [marchMember, januaryMember, juneMember, februaryMember, mayMember] = members;
2067
+
2068
+ const ids = await fetchAllPages({
2069
+ host,
2070
+ token,
2071
+ sort: [{ key: 'lastRegisteredAt', order: SortItemDirection.DESC }],
2072
+ limit: 2,
2073
+ });
2074
+
2075
+ expect(ids).toEqual([
2076
+ juneMember.id,
2077
+ mayMember.id,
2078
+ marchMember.id,
2079
+ februaryMember.id,
2080
+ januaryMember.id,
2081
+ ]);
2082
+ });
2083
+
2084
+ test('Members without a lastRegisteredAt are sorted first when ascending', async () => {
2085
+ const { host, token, members } = await setupMembers([march, null, january]);
2086
+ const [marchMember, neverRegisteredMember, januaryMember] = members;
2087
+
2088
+ // A limit of 1 puts the page boundary right after the member without a lastRegisteredAt,
2089
+ // so the next page filter is built from a null value.
2090
+ const ids = await fetchAllPages({
2091
+ host,
2092
+ token,
2093
+ sort: [{ key: 'lastRegisteredAt', order: SortItemDirection.ASC }],
2094
+ limit: 1,
2095
+ });
2096
+
2097
+ expect(ids).toEqual([
2098
+ neverRegisteredMember.id,
2099
+ januaryMember.id,
2100
+ marchMember.id,
2101
+ ]);
2102
+ });
2103
+
2104
+ test('Members without a lastRegisteredAt are sorted last when descending', async () => {
2105
+ const { host, token, members } = await setupMembers([march, null, january]);
2106
+ const [marchMember, neverRegisteredMember, januaryMember] = members;
2107
+
2108
+ const ids = await fetchAllPages({
2109
+ host,
2110
+ token,
2111
+ sort: [{ key: 'lastRegisteredAt', order: SortItemDirection.DESC }],
2112
+ limit: 1,
2113
+ });
2114
+
2115
+ expect(ids).toEqual([
2116
+ marchMember.id,
2117
+ januaryMember.id,
2118
+ neverRegisteredMember.id,
2119
+ ]);
2120
+ });
2121
+
2122
+ test('The next page filter compares lastRegisteredAt as a UTC datetime string', async () => {
2123
+ const { host, token, members } = await setupMembers([january, february]);
2124
+ const [januaryMember] = members;
2125
+
2126
+ const request = Request.get({
2127
+ path: baseUrl,
2128
+ host,
2129
+ query: new LimitedFilteredRequest({
2130
+ sort: [{ key: 'lastRegisteredAt', order: SortItemDirection.ASC }],
2131
+ limit: 1,
2132
+ }),
2133
+ headers: {
2134
+ authorization: 'Bearer ' + token.accessToken,
2135
+ },
2136
+ });
2137
+
2138
+ const response = await testServer.test(endpoint, request);
2139
+ expect(response.status).toBe(200);
2140
+ expect(response.body.results.members).toHaveLength(1);
2141
+
2142
+ // The value has to be formatted in UTC, because that is how MySQL stores the datetime column.
2143
+ // Comparing against a raw Date or a localized string would shift the page boundary.
2144
+ expect(response.body.next?.pageFilter).toMatchObject({
2145
+ $or: [
2146
+ { lastRegisteredAt: { $gt: '2023-01-10 08:30:00' } },
2147
+ {
2148
+ $and: [
2149
+ { lastRegisteredAt: '2023-01-10 08:30:00' },
2150
+ { id: { $gt: januaryMember.id } },
2151
+ ],
2152
+ },
2153
+ ],
2154
+ });
2155
+ });
2156
+
2157
+ test('Members are sorted by createdAt across all pages, even if they never registered', async () => {
2158
+ const { host, token, members } = await setupMembers([null, null, null]);
2159
+ const [first, second, third] = members;
2160
+
2161
+ // createdAt keeps an explicitly set value on save
2162
+ first.createdAt = march;
2163
+ second.createdAt = january;
2164
+ third.createdAt = february;
2165
+
2166
+ await first.save();
2167
+ await second.save();
2168
+ await third.save();
2169
+
2170
+ const ids = await fetchAllPages({
2171
+ host,
2172
+ token,
2173
+ sort: [{ key: 'createdAt', order: SortItemDirection.ASC }],
2174
+ limit: 1,
2175
+ });
2176
+
2177
+ expect(ids).toEqual([
2178
+ second.id,
2179
+ third.id,
2180
+ first.id,
2181
+ ]);
2182
+ });
2183
+ });
1962
2184
  });