@edifice.io/communities-tests 1.0.0-develop-pedago.20250725171105

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.
@@ -0,0 +1,858 @@
1
+ // @ts-ignore
2
+ import { chai, describe } from "https://jslib.k6.io/k6chaijs/4.3.4.0/index.js";
3
+ import { fail, check } from "k6";
4
+
5
+ import {
6
+ authenticateWeb,
7
+ getUsersOfSchool,
8
+ createAndSetRole,
9
+ linkRoleToUsers,
10
+ initStructure,
11
+ getRandomUserWithProfile,
12
+ getRolesOfStructure,
13
+ Structure,
14
+ logout,
15
+ UserInfo,
16
+ } from "../../node_modules/edifice-k6-commons/dist/index.js";
17
+
18
+ // Import community API utils
19
+ import {
20
+ createCommunityOrFail,
21
+ deleteCommunity,
22
+ CreateCommunityParams,
23
+ } from "./utils/_community-api.utils.ts";
24
+
25
+ // Import invitation API utils
26
+ import {
27
+ createInvitations,
28
+ listCommunityInvitations,
29
+ updateInvitationStatus,
30
+ CreateInvitationParams,
31
+ Invitation,
32
+ InvitationStatus,
33
+ MembershipRole,
34
+ InvitationSortField,
35
+ SortDirection,
36
+ updateLastVisit,
37
+ CommunitySection,
38
+ } from "./utils/_invitation-api.utils.ts";
39
+
40
+ chai.config.logFailures = true;
41
+
42
+ export const options = {
43
+ setupTimeout: "1h",
44
+ maxRedirects: 0,
45
+ thresholds: {
46
+ checks: ["rate == 1.00"],
47
+ },
48
+ scenarios: {
49
+ invitationsSearchSortTest: {
50
+ exec: "testInvitationsSearchSort",
51
+ executor: "per-vu-iterations",
52
+ vus: 1,
53
+ maxDuration: "1m",
54
+ gracefulStop: "5s",
55
+ },
56
+ },
57
+ };
58
+
59
+ const timestamp = new Date().toISOString();
60
+ const schoolName = `IT Community Invitations Search Sort`;
61
+
62
+ export function setup(): Structure {
63
+ let structure: Structure = {} as Structure;
64
+ describe("[CommunityInvitations] Initialize data", () => {
65
+ authenticateWeb(__ENV.ADMC_LOGIN, __ENV.ADMC_PASSWORD);
66
+ structure = initStructure(schoolName, "tiny");
67
+ const role = createAndSetRole("Communities");
68
+ const groups = getRolesOfStructure(structure.id);
69
+ linkRoleToUsers(
70
+ structure,
71
+ role,
72
+ groups.map((g: any) => g.name),
73
+ );
74
+ });
75
+ return structure;
76
+ }
77
+
78
+ export function testInvitationsSearchSort(structure: Structure) {
79
+ // Community data
80
+ const communityData: CreateCommunityParams = {
81
+ title: `InvitationSearchSort - Test ${timestamp}`,
82
+ type: "FREE",
83
+ schoolYearStart: 2025,
84
+ schoolYearEnd: 2026,
85
+ discussionEnabled: true,
86
+ welcomeNote: "Test community for invitation search and sort API tests",
87
+ invitations: {
88
+ users: [],
89
+ },
90
+ };
91
+
92
+ let communityId: number;
93
+ let invitationId: number;
94
+ let adminUser: UserInfo;
95
+ let memberUser: UserInfo;
96
+ let member2User: UserInfo;
97
+ let member3User: UserInfo;
98
+
99
+ describe("[Community Invitations Search and Sort Test]", () => {
100
+ // Initial setup - Get users and authenticate admin
101
+ authenticateWeb(__ENV.ADMC_LOGIN, __ENV.ADMC_PASSWORD);
102
+
103
+ // Get users from school
104
+ const users = getUsersOfSchool(structure);
105
+ adminUser = getRandomUserWithProfile(users, "Teacher");
106
+ memberUser = getRandomUserWithProfile(users, "Student", [adminUser]);
107
+ member2User = getRandomUserWithProfile(users, "Student", [
108
+ adminUser,
109
+ memberUser,
110
+ ]);
111
+ member3User = getRandomUserWithProfile(users, "Student", [
112
+ adminUser,
113
+ memberUser,
114
+ member2User,
115
+ ]);
116
+
117
+ logout();
118
+
119
+ // Admin authenticates
120
+ const adminAuthenticated = authenticateWeb(adminUser.login, "password");
121
+ if (!adminAuthenticated) {
122
+ fail("Admin authentication failed");
123
+ }
124
+
125
+ // Create a community for testing
126
+ describe("Create community and invitations for search/sort tests", () => {
127
+ // Create community
128
+ communityId = Number(createCommunityOrFail(communityData));
129
+ console.log(`Created community with ID: ${communityId}`);
130
+
131
+ // Create first invitation (will be accepted later)
132
+ const invitationData: CreateInvitationParams = {
133
+ users: [{ userId: memberUser.id, role: MembershipRole.MEMBER }],
134
+ message: "You are invited to join our test community",
135
+ };
136
+
137
+ const invitations = createInvitations(communityId, invitationData);
138
+ invitationId = invitations![0].id;
139
+ console.log(`Created invitation with ID: ${invitationId}`);
140
+
141
+ // Create additional invitations for different users to test search/sort
142
+ for (let i = 0; i < 2; i++) {
143
+ const additionalData: CreateInvitationParams = {
144
+ users: [
145
+ {
146
+ userId: i === 0 ? member2User.id : member3User.id,
147
+ role: i === 0 ? MembershipRole.MEMBER : MembershipRole.ADMIN,
148
+ },
149
+ ],
150
+ message: `Test invitation ${i + 1} for search/sort`,
151
+ };
152
+ createInvitations(communityId, additionalData);
153
+ }
154
+ });
155
+
156
+ // Have the first user accept their invitation to ensure mixed statuses
157
+ describe("Member accepts invitation to create mixed statuses", () => {
158
+ // Member logs in
159
+ logout();
160
+ authenticateWeb(memberUser.login, "password");
161
+
162
+ // Accept invitation
163
+ updateInvitationStatus(invitationId, InvitationStatus.ACCEPTED);
164
+
165
+ // Log back in as admin
166
+ logout();
167
+ authenticateWeb(adminUser.login, "password");
168
+ });
169
+
170
+ // Test user search with ILIKE on displayName
171
+ describe("Admin searches for invitations by user displayName", () => {
172
+ // First, we need to get the user's display name to search for
173
+ const memberDisplayName = `${memberUser.lastName} ${memberUser.firstName}`;
174
+ console.log(
175
+ `Searching for invitations with user displayName containing: ${memberDisplayName}`,
176
+ );
177
+
178
+ // Try searching with full displayName
179
+ const fullNameSearch = listCommunityInvitations(communityId, {
180
+ searchTerm: memberDisplayName,
181
+ });
182
+
183
+ check(fullNameSearch, {
184
+ "can search invitations by full displayName": (r) =>
185
+ r !== null && r.length > 0,
186
+ "search results contain the right invitation": (r) => {
187
+ return (
188
+ r !== null &&
189
+ r.some(
190
+ (inv: Invitation) =>
191
+ inv.id === invitationId &&
192
+ inv.receiver?.displayName === memberDisplayName,
193
+ )
194
+ );
195
+ },
196
+ });
197
+
198
+ // Try searching with partial displayName (if displayName has multiple parts)
199
+ if (memberDisplayName && memberDisplayName.includes(" ")) {
200
+ // Take just the first part of the name (typically last name)
201
+ const partialName = memberDisplayName.split(" ")[0];
202
+
203
+ const partialNameSearch = listCommunityInvitations(communityId, {
204
+ searchTerm: partialName,
205
+ });
206
+
207
+ check(partialNameSearch, {
208
+ "can search invitations by partial displayName": (r) =>
209
+ r !== null && r.length > 0,
210
+ "partial name search finds correct invitation": (r) => {
211
+ return (
212
+ r !== null &&
213
+ r.some(
214
+ (inv: Invitation) =>
215
+ inv.id === invitationId &&
216
+ inv.receiver?.displayName.includes(partialName),
217
+ )
218
+ );
219
+ },
220
+ });
221
+ }
222
+
223
+ // Test search with lowercase (testing case insensitivity)
224
+ if (memberDisplayName) {
225
+ const lowercaseSearch = listCommunityInvitations(communityId, {
226
+ searchTerm: memberDisplayName.toLowerCase(),
227
+ });
228
+
229
+ check(lowercaseSearch, {
230
+ "search is case-insensitive": (r) => r !== null && r.length > 0,
231
+ "case-insensitive search finds correct invitation": (r) => {
232
+ return (
233
+ r !== null && r.some((inv: Invitation) => inv.id === invitationId)
234
+ );
235
+ },
236
+ });
237
+ }
238
+
239
+ // Test with a non-matching search term
240
+ const nonMatchingSearch = listCommunityInvitations(communityId, {
241
+ searchTerm: "ThisUserDoesNotExist12345",
242
+ });
243
+
244
+ check(nonMatchingSearch, {
245
+ "non-matching search returns empty results": (r) =>
246
+ r !== null && r.length === 0,
247
+ });
248
+ });
249
+
250
+ // Test sorting capabilities
251
+ describe("Admin tests invitation sorting capabilities", () => {
252
+ // First create multiple invitations to ensure we have data for meaningful sorting
253
+ console.log("Creating additional invitations for sorting tests");
254
+
255
+ // Send multiple invitations for sorting tests
256
+ const sortTestUsers = [member2User, member3User];
257
+
258
+ // Track the new invitation IDs
259
+ const sortInvitationIds: number[] = [];
260
+
261
+ // Create invitations with different messages to sort on
262
+ for (let i = 0; i < sortTestUsers.length; i++) {
263
+ const sortInvitationData: CreateInvitationParams = {
264
+ users: [{ userId: sortTestUsers[i].id, role: MembershipRole.MEMBER }],
265
+ message: `Sort test invitation ${String.fromCharCode(65 + i)}`, // "A", "B", etc.
266
+ };
267
+
268
+ const invs = createInvitations(communityId, sortInvitationData);
269
+ if (invs && invs.length > 0) {
270
+ sortInvitationIds.push(invs[0].id);
271
+ }
272
+ }
273
+
274
+ // Test sorting
275
+ describe("Sort invitations ", () => {
276
+ // Test sorting by status
277
+ const statusSortAsc = listCommunityInvitations(communityId, {
278
+ sortBy: InvitationSortField.STATUS,
279
+ sortDirection: SortDirection.ASC,
280
+ });
281
+ // We are sorting according workflow order, not alphabetically
282
+
283
+ check(statusSortAsc, {
284
+ "sort by status ASC works": (r) => r !== null && r.length > 0,
285
+
286
+ // Verify enum order using JavaScript simulation of PostgreSQL enum ordering
287
+ "Status ASC sort follows PostgreSQL enum definition order": (r) => {
288
+ if (!r || r.length <= 1) return true;
289
+
290
+ // Define expected order based on enum definition in database
291
+ const enumOrder = {
292
+ [InvitationStatus.PENDING]: 1, // First position in enum
293
+ [InvitationStatus.REQUEST]: 2, // Second position
294
+ [InvitationStatus.ACCEPTED]: 3, // Third position
295
+ [InvitationStatus.REJECTED]: 4, // Fourth position
296
+ [InvitationStatus.REQUEST_ACCEPTED]: 5, // Fifth position
297
+ [InvitationStatus.REQUEST_REJECTED]: 6, // Sixth position
298
+ };
299
+
300
+ // Create a manual sort using the enum order
301
+ const manualSort = [...r].sort((a, b) => {
302
+ return enumOrder[a.status] - enumOrder[b.status];
303
+ });
304
+
305
+ // Check if API results match our manual sort
306
+ const orderMatches = r.every(
307
+ (invitation, index) => invitation.id === manualSort[index].id,
308
+ );
309
+
310
+ if (!orderMatches) {
311
+ console.log(
312
+ "Warning: Status ASC order does not match expected enum order",
313
+ );
314
+ console.log(
315
+ "Expected order:",
316
+ manualSort.map((inv) => inv.status),
317
+ );
318
+ console.log(
319
+ "Actual order:",
320
+ r.map((inv) => inv.status),
321
+ );
322
+ }
323
+
324
+ return orderMatches;
325
+ },
326
+
327
+ // Check specific status order relationships if we have multiple statuses
328
+ "Status values are correctly ordered in ASC sort": (r) => {
329
+ if (!r || r.length <= 1) return true;
330
+
331
+ // Get indexes of various statuses
332
+ const pendingIndex = r.findIndex(
333
+ (inv) => inv.status === InvitationStatus.PENDING,
334
+ );
335
+ const requestIndex = r.findIndex(
336
+ (inv) => inv.status === InvitationStatus.REQUEST,
337
+ );
338
+ const acceptedIndex = r.findIndex(
339
+ (inv) => inv.status === InvitationStatus.ACCEPTED,
340
+ );
341
+ const rejectedIndex = r.findIndex(
342
+ (inv) => inv.status === InvitationStatus.REJECTED,
343
+ );
344
+ const requestAcceptedIndex = r.findIndex(
345
+ (inv) => inv.status === InvitationStatus.REQUEST_ACCEPTED,
346
+ );
347
+ const requestRejectedIndex = r.findIndex(
348
+ (inv) => inv.status === InvitationStatus.REQUEST_REJECTED,
349
+ );
350
+
351
+ // Check all possible pairs that exist in the results
352
+ // PENDING should come before all others
353
+ if (pendingIndex !== -1) {
354
+ if (requestIndex !== -1 && pendingIndex > requestIndex)
355
+ return false;
356
+ if (acceptedIndex !== -1 && pendingIndex > acceptedIndex)
357
+ return false;
358
+ if (rejectedIndex !== -1 && pendingIndex > rejectedIndex)
359
+ return false;
360
+ if (
361
+ requestAcceptedIndex !== -1 &&
362
+ pendingIndex > requestAcceptedIndex
363
+ )
364
+ return false;
365
+ if (
366
+ requestRejectedIndex !== -1 &&
367
+ pendingIndex > requestRejectedIndex
368
+ )
369
+ return false;
370
+ }
371
+
372
+ // REQUEST should come after PENDING but before others
373
+ if (requestIndex !== -1) {
374
+ if (acceptedIndex !== -1 && requestIndex > acceptedIndex)
375
+ return false;
376
+ if (rejectedIndex !== -1 && requestIndex > rejectedIndex)
377
+ return false;
378
+ if (
379
+ requestAcceptedIndex !== -1 &&
380
+ requestIndex > requestAcceptedIndex
381
+ )
382
+ return false;
383
+ if (
384
+ requestRejectedIndex !== -1 &&
385
+ requestIndex > requestRejectedIndex
386
+ )
387
+ return false;
388
+ }
389
+ return true;
390
+ },
391
+ });
392
+
393
+ // Verify we get the same results for DESC
394
+ const statusSortDesc = listCommunityInvitations(communityId, {
395
+ sortBy: InvitationSortField.STATUS,
396
+ sortDirection: SortDirection.DESC,
397
+ });
398
+ // We are sorting according workflow order, not alphabetically
399
+ check(statusSortDesc, {
400
+ "sort by status DESC works": (r) => r !== null && r.length > 0,
401
+
402
+ // Verify enum order using JavaScript simulation of PostgreSQL DESC enum ordering
403
+ "Status DESC sort follows reverse PostgreSQL enum definition order": (
404
+ r,
405
+ ) => {
406
+ if (!r || r.length <= 1) return true;
407
+
408
+ // Define expected order based on enum definition in database
409
+ const enumOrder = {
410
+ [InvitationStatus.PENDING]: 1,
411
+ [InvitationStatus.REQUEST]: 2,
412
+ [InvitationStatus.ACCEPTED]: 3,
413
+ [InvitationStatus.REJECTED]: 4,
414
+ [InvitationStatus.REQUEST_ACCEPTED]: 5,
415
+ [InvitationStatus.REQUEST_REJECTED]: 6,
416
+ };
417
+
418
+ // Create a manual sort using the enum order (reversed for DESC)
419
+ const manualSort = [...r].sort((a, b) => {
420
+ return enumOrder[b.status] - enumOrder[a.status];
421
+ });
422
+
423
+ // Check if API results match our manual sort
424
+ const orderMatches = r.every(
425
+ (invitation, index) => invitation.id === manualSort[index].id,
426
+ );
427
+
428
+ if (!orderMatches) {
429
+ console.log(
430
+ "Warning: Status DESC order does not match expected enum order",
431
+ );
432
+ console.log(
433
+ "Expected order:",
434
+ manualSort.map((inv) => inv.status),
435
+ );
436
+ console.log(
437
+ "Actual order:",
438
+ r.map((inv) => inv.status),
439
+ );
440
+ }
441
+
442
+ return orderMatches;
443
+ },
444
+ });
445
+
446
+ // Test sorting by userName
447
+ const userNameSortAsc = listCommunityInvitations(communityId, {
448
+ sortBy: InvitationSortField.USER_NAME,
449
+ sortDirection: SortDirection.ASC,
450
+ });
451
+
452
+ check(userNameSortAsc, {
453
+ "sort by userName ASC works": (r) => r !== null && r.length > 0,
454
+ // Add JavaScript-based validation of server-side sorting
455
+ "API userName ASC sort matches JavaScript sort": (r) => {
456
+ if (!r || r.length <= 1) return true; // Nothing to validate with 0-1 items
457
+
458
+ // Create a copy and sort manually by displayName using JavaScript's sort
459
+ const manualSort = [...r].sort((a, b) => {
460
+ const nameA = a.receiver?.displayName || "";
461
+ const nameB = b.receiver?.displayName || "";
462
+ return nameA.localeCompare(nameB); // Use localeCompare for proper string comparison
463
+ });
464
+ // Log the original and manually sorted displayNames for debugging
465
+ console.log(
466
+ "API sorted names:",
467
+ r.map((inv) => inv.receiver?.displayName),
468
+ );
469
+ console.log(
470
+ "JS sorted names:",
471
+ manualSort.map((inv) => inv.receiver?.displayName),
472
+ );
473
+
474
+ // Check if the order matches by comparing IDs at each position
475
+ const orderMatches = r.every(
476
+ (invitation, index) => invitation.id === manualSort[index].id,
477
+ );
478
+ if (!orderMatches) {
479
+ console.log(
480
+ "Warning: API sort order does not match JavaScript sort order",
481
+ );
482
+ }
483
+ return orderMatches;
484
+ },
485
+ });
486
+
487
+ const userNameSortDesc = listCommunityInvitations(communityId, {
488
+ sortBy: InvitationSortField.USER_NAME,
489
+ sortDirection: SortDirection.DESC,
490
+ });
491
+
492
+ check(userNameSortDesc, {
493
+ "sort by userName DESC works": (r) => r !== null && r.length > 0,
494
+ "userName DESC returns different order than ASC": (r) => {
495
+ // This only works if we have multiple results
496
+ if (
497
+ !r ||
498
+ !userNameSortAsc ||
499
+ r.length <= 1 ||
500
+ userNameSortAsc.length <= 1
501
+ )
502
+ return true;
503
+
504
+ // Check first user in each list - should be different for different sort directions
505
+ return r[0].id !== userNameSortAsc[0].id;
506
+ },
507
+ // Add JavaScript-based validation of server-side sorting for DESC
508
+ "API userName DESC sort matches JavaScript sort": (r) => {
509
+ if (!r || r.length <= 1) return true;
510
+
511
+ // Create a copy and sort manually by displayName in descending order
512
+ const manualSort = [...r].sort((a, b) => {
513
+ const nameA = a.receiver?.displayName || "";
514
+ const nameB = b.receiver?.displayName || "";
515
+ return nameB.localeCompare(nameA); // Reversed comparison for DESC sort
516
+ });
517
+
518
+ // Check if the order matches
519
+ return r.every(
520
+ (invitation, index) => invitation.id === manualSort[index].id,
521
+ );
522
+ },
523
+ });
524
+ });
525
+
526
+ // Test sorting by currentRole
527
+ const roleSortAsc = listCommunityInvitations(communityId, {
528
+ sortBy: InvitationSortField.CURRENT_ROLE,
529
+ sortDirection: SortDirection.ASC,
530
+ });
531
+
532
+ console.log(
533
+ "Role sort ASC result:",
534
+ roleSortAsc?.map((inv) => ({
535
+ id: inv.id,
536
+ role: inv.currentRole,
537
+ user: inv.receiver?.displayName,
538
+ })),
539
+ );
540
+
541
+ check(roleSortAsc, {
542
+ "sort by currentRole ASC works": (r) => r !== null && r.length > 0,
543
+
544
+ // Add verification of correct role order for ASC
545
+ "Role ASC sort follows PostgreSQL enum definition order": (r) => {
546
+ if (!r || r.length <= 1) return true;
547
+
548
+ // Define expected order based on enum definition in database
549
+ // In PostgreSQL, ADMIN is defined before MEMBER
550
+ const enumOrder = {
551
+ [MembershipRole.ADMIN]: 1, // First position in enum
552
+ [MembershipRole.MEMBER]: 2, // Second position in enum
553
+ };
554
+
555
+ // Create a manual sort using the enum order
556
+ const manualSort = [...r].sort((a, b) => {
557
+ // Use initial_role for entries without current_role (non-accepted invitations)
558
+ const roleA = a.currentRole || a.initialRole;
559
+ const roleB = b.currentRole || b.initialRole;
560
+
561
+ return enumOrder[roleA!] - enumOrder[roleB!];
562
+ });
563
+
564
+ // Check if API results match our manual sort
565
+ const orderMatches = r.every(
566
+ (invitation, index) => invitation.id === manualSort[index].id,
567
+ );
568
+
569
+ if (!orderMatches) {
570
+ console.log(
571
+ "Warning: Role ASC order does not match expected enum order",
572
+ );
573
+ console.log(
574
+ "Expected order:",
575
+ manualSort.map((inv) => inv.currentRole || inv.initialRole),
576
+ );
577
+ console.log(
578
+ "Actual order:",
579
+ r.map((inv) => inv.currentRole || inv.initialRole),
580
+ );
581
+ }
582
+
583
+ return orderMatches;
584
+ },
585
+
586
+ // Add specific check for ADMIN appearing before MEMBER
587
+ "ADMIN appears before MEMBER in ASC sort": (r) => {
588
+ if (!r || r.length <= 1) return true;
589
+
590
+ const adminIndex = r.findIndex(
591
+ (inv) =>
592
+ (inv.currentRole || inv.initialRole) === MembershipRole.ADMIN,
593
+ );
594
+
595
+ const memberIndex = r.findIndex(
596
+ (inv) =>
597
+ (inv.currentRole || inv.initialRole) === MembershipRole.MEMBER,
598
+ );
599
+
600
+ // If both roles exist, ADMIN should appear first in ASC order
601
+ // since that's the PostgreSQL enum definition order
602
+ if (adminIndex !== -1 && memberIndex !== -1) {
603
+ return adminIndex < memberIndex;
604
+ }
605
+
606
+ // If only one role exists or none, test passes
607
+ return true;
608
+ },
609
+ });
610
+
611
+ // Test with DESC
612
+ const roleSortDesc = listCommunityInvitations(communityId, {
613
+ sortBy: InvitationSortField.CURRENT_ROLE,
614
+ sortDirection: SortDirection.DESC,
615
+ });
616
+
617
+ console.log(
618
+ "Role sort DESC result:",
619
+ roleSortDesc?.map((inv) => ({
620
+ id: inv.id,
621
+ role: inv.currentRole,
622
+ user: inv.receiver?.displayName,
623
+ })),
624
+ );
625
+
626
+ check(roleSortDesc, {
627
+ "sort by currentRole DESC works": (r) => r !== null && r.length > 0,
628
+
629
+ // Add verification of correct role order for DESC (reversed)
630
+ "Role DESC sort follows reverse PostgreSQL enum definition order": (
631
+ r,
632
+ ) => {
633
+ if (!r || r.length <= 1) return true;
634
+
635
+ // Define expected order based on enum definition in database
636
+ const enumOrder = {
637
+ [MembershipRole.ADMIN]: 1, // First position in enum
638
+ [MembershipRole.MEMBER]: 2, // Second position in enum
639
+ };
640
+
641
+ // Create a manual sort using the reversed enum order for DESC
642
+ const manualSort = [...r].sort((a, b) => {
643
+ // Use initial_role for entries without current_role
644
+ const roleA = a.currentRole || a.initialRole;
645
+ const roleB = b.currentRole || b.initialRole;
646
+
647
+ return enumOrder[roleB!] - enumOrder[roleA!]; // Notice the reversed order for DESC
648
+ });
649
+
650
+ // Check if API results match our manual sort
651
+ const orderMatches = r.every(
652
+ (invitation, index) => invitation.id === manualSort[index].id,
653
+ );
654
+
655
+ if (!orderMatches) {
656
+ console.log(
657
+ "Warning: Role DESC order does not match expected enum order",
658
+ );
659
+ console.log(
660
+ "Expected order:",
661
+ manualSort.map((inv) => inv.currentRole || inv.initialRole),
662
+ );
663
+ console.log(
664
+ "Actual order:",
665
+ r.map((inv) => inv.currentRole || inv.initialRole),
666
+ );
667
+ }
668
+
669
+ return orderMatches;
670
+ },
671
+
672
+ // Add specific check for MEMBER appearing before ADMIN in DESC
673
+ "MEMBER appears before ADMIN in DESC sort": (r) => {
674
+ if (!r || r.length <= 1) return true;
675
+
676
+ const adminIndex = r.findIndex(
677
+ (inv) =>
678
+ (inv.currentRole || inv.initialRole) === MembershipRole.ADMIN,
679
+ );
680
+
681
+ const memberIndex = r.findIndex(
682
+ (inv) =>
683
+ (inv.currentRole || inv.initialRole) === MembershipRole.MEMBER,
684
+ );
685
+
686
+ // If both roles exist, MEMBER should come first in DESC order
687
+ if (adminIndex !== -1 && memberIndex !== -1) {
688
+ return memberIndex < adminIndex;
689
+ }
690
+
691
+ // If only one role exists or none, test passes
692
+ return true;
693
+ },
694
+ });
695
+
696
+ // Add this section before testing lastVisit sorting
697
+
698
+ // Set up lastVisit data for accepted invitations
699
+ describe("Add lastVisit data for sorting tests", () => {
700
+ // Get all accepted invitations
701
+ const allInvitations = listCommunityInvitations(communityId);
702
+ console.log(`Found ${allInvitations?.length || 0} total invitations`);
703
+
704
+ // Find the accepted invitation for our test user
705
+ const acceptedInvitation = allInvitations?.find(
706
+ (inv) => inv.status === InvitationStatus.ACCEPTED,
707
+ );
708
+
709
+ if (acceptedInvitation) {
710
+ console.log(
711
+ `Found accepted invitation for user ${acceptedInvitation.receiver?.displayName}`,
712
+ );
713
+
714
+ // Login as this user to update lastVisit
715
+ logout();
716
+ authenticateWeb(memberUser.login, "password");
717
+
718
+ // Create a timestamp to ensure we have data for sorting
719
+ console.log(`Updating lastVisit data for user ${memberUser.login}`);
720
+
721
+ // Visit different sections with different timestamps
722
+ // This will update lastVisit in the membership entity
723
+ const result = updateLastVisit(
724
+ communityId,
725
+ CommunitySection.ANNOUNCEMENTS,
726
+ );
727
+ check(result, {
728
+ "updateLastVisit successfully sets lastVisit date": (r) =>
729
+ r !== null,
730
+ });
731
+
732
+ // Log back in as admin to proceed with tests
733
+ logout();
734
+ authenticateWeb(adminUser.login, "password");
735
+ } else {
736
+ console.log("No accepted invitations found to update lastVisit");
737
+ }
738
+
739
+ // Test sorting by lastVisit
740
+ const lastVisitSortAsc = listCommunityInvitations(communityId, {
741
+ sortBy: InvitationSortField.LAST_VISIT,
742
+ sortDirection: SortDirection.ASC,
743
+ });
744
+
745
+ console.log(
746
+ "LastVisit ASC sorting results:",
747
+ lastVisitSortAsc?.map((inv) => ({
748
+ id: inv.id,
749
+ status: inv.status,
750
+ user: inv.receiver?.displayName,
751
+ lastVisit: inv.lastVisit || "null",
752
+ })),
753
+ );
754
+
755
+ check(lastVisitSortAsc, {
756
+ "sort by lastVisit ASC works": (r) => r !== null && r.length > 0,
757
+ "lastVisit ASC sort handles null values correctly": (r) => {
758
+ if (!r || r.length <= 1) return true;
759
+
760
+ // Create a manual sort using JavaScript - PostgreSQL uses NULLS LAST behavior
761
+ const manualSort = [...r].sort((a, b) => {
762
+ // If both have lastVisit, compare dates
763
+ if (a.lastVisit && b.lastVisit) {
764
+ return (
765
+ new Date(a.lastVisit).getTime() -
766
+ new Date(b.lastVisit).getTime()
767
+ );
768
+ }
769
+ // Handle null cases (NULLS LAST behavior)
770
+ if (!a.lastVisit && b.lastVisit) return 1; // a (null) goes after b
771
+ if (a.lastVisit && !b.lastVisit) return -1; // a goes before b (null)
772
+ return 0; // Both null, keep original order
773
+ });
774
+
775
+ // Check if the order matches by comparing IDs at each position
776
+ const orderMatches = r.every(
777
+ (invitation, index) => invitation.id === manualSort[index].id,
778
+ );
779
+
780
+ if (!orderMatches) {
781
+ console.log(
782
+ "Warning: API lastVisit ASC sort order does not match JavaScript sort",
783
+ );
784
+ console.log(
785
+ "Expected order:",
786
+ manualSort.map((inv) => ({
787
+ id: inv.id,
788
+ lastVisit: inv.lastVisit,
789
+ })),
790
+ );
791
+ console.log(
792
+ "Actual order:",
793
+ r.map((inv) => ({ id: inv.id, lastVisit: inv.lastVisit })),
794
+ );
795
+ }
796
+
797
+ return orderMatches;
798
+ },
799
+ });
800
+
801
+ // Test with DESC
802
+ const lastVisitSortDesc = listCommunityInvitations(communityId, {
803
+ sortBy: InvitationSortField.LAST_VISIT,
804
+ sortDirection: SortDirection.DESC,
805
+ });
806
+
807
+ check(lastVisitSortDesc, {
808
+ "sort by lastVisit DESC works": (r) => r !== null && r.length > 0,
809
+ "lastVisit DESC reverses non-null values but maintains null placement":
810
+ (r) => {
811
+ if (!r || !lastVisitSortAsc || r.length <= 1) return true;
812
+
813
+ // Get entries with lastVisit from both sorts
814
+ const withLastVisitAsc = lastVisitSortAsc.filter(
815
+ (inv) => inv.lastVisit,
816
+ );
817
+ const withLastVisitDesc = r.filter((inv) => inv.lastVisit);
818
+
819
+ // If we don't have enough entries with lastVisit, test passes
820
+ if (withLastVisitAsc.length <= 1 || withLastVisitDesc.length <= 1)
821
+ return true;
822
+
823
+ // In DESC, the non-null values should be in reversed order compared to ASC
824
+ // Use JavaScript's sorting implementation to verify
825
+ const manualSort = [...withLastVisitAsc].sort((a, b) => {
826
+ return (
827
+ new Date(b.lastVisit!).getTime() -
828
+ new Date(a.lastVisit!).getTime()
829
+ );
830
+ });
831
+
832
+ // Check if the DESC API results match our manually reversed sort
833
+ const orderMatches = withLastVisitDesc.every(
834
+ (invitation, index) => invitation.id === manualSort[index].id,
835
+ );
836
+
837
+ if (!orderMatches) {
838
+ console.log(
839
+ "Warning: API lastVisit DESC sort does not reverse ASC correctly",
840
+ );
841
+ }
842
+
843
+ return orderMatches;
844
+ },
845
+ });
846
+ });
847
+
848
+ // Cleanup - Delete the community
849
+ describe("Cleanup - Delete test community", () => {
850
+ const deleted = deleteCommunity(communityId);
851
+
852
+ check(deleted, {
853
+ "community deleted successfully": (r) => r === true,
854
+ });
855
+ });
856
+ });
857
+ });
858
+ }