A social knowledge tool for researchers built on ATProto
1import {
2 PostgreSqlContainer,
3 StartedPostgreSqlContainer,
4} from '@testcontainers/postgresql';
5import postgres from 'postgres';
6import { drizzle, PostgresJsDatabase } from 'drizzle-orm/postgres-js';
7import { DrizzleCollectionQueryRepository } from '../../infrastructure/repositories/DrizzleCollectionQueryRepository';
8import { DrizzleCardRepository } from '../../infrastructure/repositories/DrizzleCardRepository';
9import { DrizzleCollectionRepository } from '../../infrastructure/repositories/DrizzleCollectionRepository';
10import { CuratorId } from '../../domain/value-objects/CuratorId';
11import { UniqueEntityID } from '../../../../shared/domain/UniqueEntityID';
12import {
13 collections,
14 collectionCollaborators,
15 collectionCards,
16} from '../../infrastructure/repositories/schema/collection.sql';
17import { cards } from '../../infrastructure/repositories/schema/card.sql';
18import { libraryMemberships } from '../../infrastructure/repositories/schema/libraryMembership.sql';
19import { publishedRecords } from '../../infrastructure/repositories/schema/publishedRecord.sql';
20import { Collection, CollectionAccessType } from '../../domain/Collection';
21import { CardFactory } from '../../domain/CardFactory';
22import { CardTypeEnum } from '../../domain/value-objects/CardType';
23import {
24 CollectionSortField,
25 SortOrder,
26} from '../../domain/ICollectionQueryRepository';
27import { createTestSchema } from '../test-utils/createTestSchema';
28import { CollectionBuilder } from '../utils/builders/CollectionBuilder';
29import { FakeCollectionPublisher } from '../utils/FakeCollectionPublisher';
30
31describe('DrizzleCollectionQueryRepository', () => {
32 let container: StartedPostgreSqlContainer;
33 let db: PostgresJsDatabase;
34 let queryRepository: DrizzleCollectionQueryRepository;
35 let collectionRepository: DrizzleCollectionRepository;
36 let cardRepository: DrizzleCardRepository;
37 let fakePublisher: FakeCollectionPublisher;
38
39 // Test data
40 let curatorId: CuratorId;
41 let otherCuratorId: CuratorId;
42
43 // Setup before all tests
44 beforeAll(async () => {
45 // Start PostgreSQL container
46 container = await new PostgreSqlContainer('postgres:14').start();
47
48 // Create database connection
49 const connectionString = container.getConnectionUri();
50 process.env.DATABASE_URL = connectionString;
51 const client = postgres(connectionString);
52 db = drizzle(client);
53
54 // Create repositories
55 queryRepository = new DrizzleCollectionQueryRepository(db);
56 collectionRepository = new DrizzleCollectionRepository(db);
57 cardRepository = new DrizzleCardRepository(db);
58 fakePublisher = new FakeCollectionPublisher();
59
60 // Create schema using helper function
61 await createTestSchema(db);
62
63 // Create test data
64 curatorId = CuratorId.create('did:plc:testcurator').unwrap();
65 otherCuratorId = CuratorId.create('did:plc:othercurator').unwrap();
66 }, 60000); // Increase timeout for container startup
67
68 // Cleanup after all tests
69 afterAll(async () => {
70 // Stop container
71 await container.stop();
72 });
73
74 // Clear data between tests
75 beforeEach(async () => {
76 await db.delete(collectionCards);
77 await db.delete(collectionCollaborators);
78 await db.delete(collections);
79 await db.delete(libraryMemberships);
80 await db.delete(cards);
81 await db.delete(publishedRecords);
82 // Clear fake publisher state between tests
83 fakePublisher.clear();
84 });
85
86 describe('findByCreator', () => {
87 it('should return empty result when curator has no collections', async () => {
88 const result = await queryRepository.findByCreator(curatorId.value, {
89 page: 1,
90 limit: 10,
91 sortBy: CollectionSortField.UPDATED_AT,
92 sortOrder: SortOrder.DESC,
93 });
94
95 expect(result.items).toHaveLength(0);
96 expect(result.totalCount).toBe(0);
97 expect(result.hasMore).toBe(false);
98 });
99
100 it('should return collections for a curator', async () => {
101 // Create test collections
102 const collection1 = Collection.create(
103 {
104 authorId: curatorId,
105 name: 'First Collection',
106 description: 'First description',
107 accessType: CollectionAccessType.OPEN,
108 collaboratorIds: [],
109 createdAt: new Date('2023-01-01'),
110 updatedAt: new Date('2023-01-01'),
111 },
112 new UniqueEntityID(),
113 ).unwrap();
114
115 const collection2 = Collection.create(
116 {
117 authorId: curatorId,
118 name: 'Second Collection',
119 accessType: CollectionAccessType.CLOSED,
120 collaboratorIds: [],
121 createdAt: new Date('2023-01-02'),
122 updatedAt: new Date('2023-01-02'),
123 },
124 new UniqueEntityID(),
125 ).unwrap();
126
127 // Save collections
128 await collectionRepository.save(collection1);
129 await collectionRepository.save(collection2);
130
131 // Query collections
132 const result = await queryRepository.findByCreator(curatorId.value, {
133 page: 1,
134 limit: 10,
135 sortBy: CollectionSortField.UPDATED_AT,
136 sortOrder: SortOrder.DESC,
137 });
138
139 expect(result.items).toHaveLength(2);
140 expect(result.totalCount).toBe(2);
141 expect(result.hasMore).toBe(false);
142
143 // Check collection data
144 const names = result.items.map((item) => item.name);
145 expect(names).toContain('First Collection');
146 expect(names).toContain('Second Collection');
147
148 // Check that all items have the correct curator
149 result.items.forEach((item) => {
150 expect(item.authorId).toBe(curatorId.value);
151 });
152 });
153
154 it('should not return collections from other curators', async () => {
155 // Create collection for other curator
156 const otherCollection = Collection.create(
157 {
158 authorId: otherCuratorId,
159 name: "Other's Collection",
160 accessType: CollectionAccessType.OPEN,
161 collaboratorIds: [],
162 createdAt: new Date(),
163 updatedAt: new Date(),
164 },
165 new UniqueEntityID(),
166 ).unwrap();
167
168 await collectionRepository.save(otherCollection);
169
170 // Query collections for our curator
171 const result = await queryRepository.findByCreator(curatorId.value, {
172 page: 1,
173 limit: 10,
174 sortBy: CollectionSortField.UPDATED_AT,
175 sortOrder: SortOrder.DESC,
176 });
177
178 expect(result.items).toHaveLength(0);
179 expect(result.totalCount).toBe(0);
180 });
181
182 it('should include card count for collections', async () => {
183 // Create a card
184 const cardResult = CardFactory.create({
185 curatorId: curatorId.value,
186 cardInput: {
187 type: CardTypeEnum.NOTE,
188 text: 'Test card',
189 },
190 });
191 const card = cardResult.unwrap();
192 await cardRepository.save(card);
193
194 // Create collection with cards
195 const collection = Collection.create(
196 {
197 authorId: curatorId,
198 name: 'Collection with Cards',
199 accessType: CollectionAccessType.OPEN,
200 collaboratorIds: [],
201 createdAt: new Date(),
202 updatedAt: new Date(),
203 },
204 new UniqueEntityID(),
205 ).unwrap();
206
207 // Add card to collection
208 collection.addCard(card.cardId, curatorId);
209 await collectionRepository.save(collection);
210
211 // Create collection without cards
212 const emptyCollection = Collection.create(
213 {
214 authorId: curatorId,
215 name: 'Empty Collection',
216 accessType: CollectionAccessType.OPEN,
217 collaboratorIds: [],
218 createdAt: new Date(),
219 updatedAt: new Date(),
220 },
221 new UniqueEntityID(),
222 ).unwrap();
223
224 await collectionRepository.save(emptyCollection);
225
226 // Query collections
227 const result = await queryRepository.findByCreator(curatorId.value, {
228 page: 1,
229 limit: 10,
230 sortBy: CollectionSortField.UPDATED_AT,
231 sortOrder: SortOrder.DESC,
232 });
233
234 expect(result.items).toHaveLength(2);
235
236 // Find the collections by name and check card counts
237 const collectionWithCards = result.items.find(
238 (item) => item.name === 'Collection with Cards',
239 );
240 const collectionWithoutCards = result.items.find(
241 (item) => item.name === 'Empty Collection',
242 );
243
244 expect(collectionWithCards?.cardCount).toBe(1);
245 expect(collectionWithoutCards?.cardCount).toBe(0);
246 });
247 });
248
249 describe('sorting', () => {
250 beforeEach(async () => {
251 // Create test collections with different properties for sorting
252 const collection1 = Collection.create(
253 {
254 authorId: curatorId,
255 name: 'Alpha Collection',
256 description: 'First alphabetically',
257 accessType: CollectionAccessType.OPEN,
258 collaboratorIds: [],
259 createdAt: new Date('2023-01-01T10:00:00Z'),
260 updatedAt: new Date('2023-01-03T10:00:00Z'), // Most recently updated
261 },
262 new UniqueEntityID(),
263 ).unwrap();
264
265 const collection2 = Collection.create(
266 {
267 authorId: curatorId,
268 name: 'Beta Collection',
269 description: 'Second alphabetically',
270 accessType: CollectionAccessType.OPEN,
271 collaboratorIds: [],
272 createdAt: new Date('2023-01-02T10:00:00Z'), // Most recently created
273 updatedAt: new Date('2023-01-02T10:00:00Z'),
274 },
275 new UniqueEntityID(),
276 ).unwrap();
277
278 const collection3 = Collection.create(
279 {
280 authorId: curatorId,
281 name: 'Gamma Collection',
282 description: 'Third alphabetically',
283 accessType: CollectionAccessType.OPEN,
284 collaboratorIds: [],
285 createdAt: new Date('2023-01-01T09:00:00Z'), // Oldest created
286 updatedAt: new Date('2023-01-01T09:00:00Z'), // Oldest updated
287 },
288 new UniqueEntityID(),
289 ).unwrap();
290
291 // Save collections
292 await collectionRepository.save(collection1);
293 await collectionRepository.save(collection2);
294 await collectionRepository.save(collection3);
295
296 // Create cards and add different numbers to collections for card count sorting
297 const card1 = CardFactory.create({
298 curatorId: curatorId.value,
299 cardInput: { type: CardTypeEnum.NOTE, text: 'Card 1' },
300 }).unwrap();
301 const card2 = CardFactory.create({
302 curatorId: curatorId.value,
303 cardInput: { type: CardTypeEnum.NOTE, text: 'Card 2' },
304 }).unwrap();
305 const card3 = CardFactory.create({
306 curatorId: curatorId.value,
307 cardInput: { type: CardTypeEnum.NOTE, text: 'Card 3' },
308 }).unwrap();
309
310 await cardRepository.save(card1);
311 await cardRepository.save(card2);
312 await cardRepository.save(card3);
313
314 // Add cards to collections: Gamma gets 3 cards, Beta gets 1, Alpha gets 0
315 collection3.addCard(card1.cardId, curatorId);
316 collection3.addCard(card2.cardId, curatorId);
317 collection3.addCard(card3.cardId, curatorId);
318 collection2.addCard(card1.cardId, curatorId);
319
320 await collectionRepository.save(collection2);
321 await collectionRepository.save(collection3);
322 });
323
324 it('should sort by name ascending', async () => {
325 const result = await queryRepository.findByCreator(curatorId.value, {
326 page: 1,
327 limit: 10,
328 sortBy: CollectionSortField.NAME,
329 sortOrder: SortOrder.ASC,
330 });
331
332 expect(result.items).toHaveLength(3);
333 expect(result.items[0]?.name).toBe('Alpha Collection');
334 expect(result.items[1]?.name).toBe('Beta Collection');
335 expect(result.items[2]?.name).toBe('Gamma Collection');
336 });
337
338 it('should sort by name descending', async () => {
339 const result = await queryRepository.findByCreator(curatorId.value, {
340 page: 1,
341 limit: 10,
342 sortBy: CollectionSortField.NAME,
343 sortOrder: SortOrder.DESC,
344 });
345
346 expect(result.items).toHaveLength(3);
347 expect(result.items[0]?.name).toBe('Gamma Collection');
348 expect(result.items[1]?.name).toBe('Beta Collection');
349 expect(result.items[2]?.name).toBe('Alpha Collection');
350 });
351
352 it('should sort by created date ascending', async () => {
353 const result = await queryRepository.findByCreator(curatorId.value, {
354 page: 1,
355 limit: 10,
356 sortBy: CollectionSortField.CREATED_AT,
357 sortOrder: SortOrder.ASC,
358 });
359
360 expect(result.items).toHaveLength(3);
361 expect(result.items[0]?.name).toBe('Gamma Collection'); // Oldest
362 expect(result.items[1]?.name).toBe('Alpha Collection');
363 expect(result.items[2]?.name).toBe('Beta Collection'); // Newest
364 });
365
366 it('should sort by created date descending', async () => {
367 const result = await queryRepository.findByCreator(curatorId.value, {
368 page: 1,
369 limit: 10,
370 sortBy: CollectionSortField.CREATED_AT,
371 sortOrder: SortOrder.DESC,
372 });
373
374 expect(result.items).toHaveLength(3);
375 expect(result.items[0]?.name).toBe('Beta Collection'); // Newest
376 expect(result.items[1]?.name).toBe('Alpha Collection');
377 expect(result.items[2]?.name).toBe('Gamma Collection'); // Oldest
378 });
379
380 it('should sort by updated date ascending', async () => {
381 const result = await queryRepository.findByCreator(curatorId.value, {
382 page: 1,
383 limit: 10,
384 sortBy: CollectionSortField.UPDATED_AT,
385 sortOrder: SortOrder.ASC,
386 });
387
388 expect(result.items).toHaveLength(3);
389 expect(result.items[0]!.updatedAt.getTime()).toBeLessThanOrEqual(
390 result.items[1]!.updatedAt.getTime(),
391 );
392 expect(result.items[1]!.updatedAt.getTime()).toBeLessThanOrEqual(
393 result.items[2]!.updatedAt.getTime(),
394 );
395 });
396
397 it('should sort by updated date descending (default)', async () => {
398 const result = await queryRepository.findByCreator(curatorId.value, {
399 page: 1,
400 limit: 10,
401 sortBy: CollectionSortField.UPDATED_AT,
402 sortOrder: SortOrder.DESC,
403 });
404
405 expect(result.items).toHaveLength(3);
406 expect(result.items[0]!.updatedAt.getTime()).toBeGreaterThanOrEqual(
407 result.items[1]!.updatedAt.getTime(),
408 );
409 expect(result.items[1]!.updatedAt.getTime()).toBeGreaterThanOrEqual(
410 result.items[2]!.updatedAt.getTime(),
411 );
412 });
413
414 it('should sort by card count ascending', async () => {
415 const result = await queryRepository.findByCreator(curatorId.value, {
416 page: 1,
417 limit: 10,
418 sortBy: CollectionSortField.CARD_COUNT,
419 sortOrder: SortOrder.ASC,
420 });
421
422 expect(result.items).toHaveLength(3);
423 expect(result.items[0]?.name).toBe('Alpha Collection'); // 0 cards
424 expect(result.items[0]?.cardCount).toBe(0);
425 expect(result.items[1]?.name).toBe('Beta Collection'); // 1 card
426 expect(result.items[1]?.cardCount).toBe(1);
427 expect(result.items[2]?.name).toBe('Gamma Collection'); // 3 cards
428 expect(result.items[2]?.cardCount).toBe(3);
429 });
430
431 it('should sort by card count descending', async () => {
432 const result = await queryRepository.findByCreator(curatorId.value, {
433 page: 1,
434 limit: 10,
435 sortBy: CollectionSortField.CARD_COUNT,
436 sortOrder: SortOrder.DESC,
437 });
438
439 expect(result.items).toHaveLength(3);
440 expect(result.items[0]?.name).toBe('Gamma Collection'); // 3 cards
441 expect(result.items[0]?.cardCount).toBe(3);
442 expect(result.items[1]?.name).toBe('Beta Collection'); // 1 card
443 expect(result.items[1]?.cardCount).toBe(1);
444 expect(result.items[2]?.name).toBe('Alpha Collection'); // 0 cards
445 expect(result.items[2]?.cardCount).toBe(0);
446 });
447 });
448
449 describe('pagination', () => {
450 beforeEach(async () => {
451 // Create 5 test collections for pagination testing
452 for (let i = 1; i <= 5; i++) {
453 const collection = Collection.create(
454 {
455 authorId: curatorId,
456 name: `Collection ${i.toString().padStart(2, '0')}`,
457 description: `Description ${i}`,
458 accessType: CollectionAccessType.OPEN,
459 collaboratorIds: [],
460 createdAt: new Date(`2023-01-${i.toString().padStart(2, '0')}`),
461 updatedAt: new Date(`2023-01-${i.toString().padStart(2, '0')}`),
462 },
463 new UniqueEntityID(),
464 ).unwrap();
465
466 await collectionRepository.save(collection);
467 }
468 });
469
470 it('should handle first page with limit', async () => {
471 const result = await queryRepository.findByCreator(curatorId.value, {
472 page: 1,
473 limit: 2,
474 sortBy: CollectionSortField.NAME,
475 sortOrder: SortOrder.ASC,
476 });
477
478 expect(result.items).toHaveLength(2);
479 expect(result.totalCount).toBe(5);
480 expect(result.hasMore).toBe(true);
481 expect(result.items[0]?.name).toBe('Collection 01');
482 expect(result.items[1]?.name).toBe('Collection 02');
483 });
484
485 it('should handle second page', async () => {
486 const result = await queryRepository.findByCreator(curatorId.value, {
487 page: 2,
488 limit: 2,
489 sortBy: CollectionSortField.NAME,
490 sortOrder: SortOrder.ASC,
491 });
492
493 expect(result.items).toHaveLength(2);
494 expect(result.totalCount).toBe(5);
495 expect(result.hasMore).toBe(true);
496 expect(result.items[0]?.name).toBe('Collection 03');
497 expect(result.items[1]?.name).toBe('Collection 04');
498 });
499
500 it('should handle last page with remaining items', async () => {
501 const result = await queryRepository.findByCreator(curatorId.value, {
502 page: 3,
503 limit: 2,
504 sortBy: CollectionSortField.NAME,
505 sortOrder: SortOrder.ASC,
506 });
507
508 expect(result.items).toHaveLength(1);
509 expect(result.totalCount).toBe(5);
510 expect(result.hasMore).toBe(false);
511 expect(result.items[0]?.name).toBe('Collection 05');
512 });
513
514 it('should handle page beyond available data', async () => {
515 const result = await queryRepository.findByCreator(curatorId.value, {
516 page: 10,
517 limit: 2,
518 sortBy: CollectionSortField.NAME,
519 sortOrder: SortOrder.ASC,
520 });
521
522 expect(result.items).toHaveLength(0);
523 expect(result.totalCount).toBe(5);
524 expect(result.hasMore).toBe(false);
525 });
526
527 it('should handle large limit that exceeds total items', async () => {
528 const result = await queryRepository.findByCreator(curatorId.value, {
529 page: 1,
530 limit: 100,
531 sortBy: CollectionSortField.NAME,
532 sortOrder: SortOrder.ASC,
533 });
534
535 expect(result.items).toHaveLength(5);
536 expect(result.totalCount).toBe(5);
537 expect(result.hasMore).toBe(false);
538 });
539
540 it('should calculate hasMore correctly for exact page boundaries', async () => {
541 // Test when items exactly fill pages
542 const result = await queryRepository.findByCreator(curatorId.value, {
543 page: 1,
544 limit: 5, // Exactly matches total count
545 sortBy: CollectionSortField.NAME,
546 sortOrder: SortOrder.ASC,
547 });
548
549 expect(result.items).toHaveLength(5);
550 expect(result.totalCount).toBe(5);
551 expect(result.hasMore).toBe(false);
552 });
553 });
554
555 describe('combined sorting and pagination', () => {
556 beforeEach(async () => {
557 // Create collections with different update times and card counts
558 const collections = [
559 {
560 name: 'Alpha',
561 updatedAt: new Date('2023-01-01'),
562 cardCount: 3,
563 },
564 {
565 name: 'Beta',
566 updatedAt: new Date('2023-01-03'),
567 cardCount: 1,
568 },
569 {
570 name: 'Gamma',
571 updatedAt: new Date('2023-01-02'),
572 cardCount: 2,
573 },
574 {
575 name: 'Delta',
576 updatedAt: new Date('2023-01-04'),
577 cardCount: 0,
578 },
579 ];
580
581 for (const collectionData of collections) {
582 const collection = Collection.create(
583 {
584 authorId: curatorId,
585 name: collectionData.name,
586 accessType: CollectionAccessType.OPEN,
587 collaboratorIds: [],
588 createdAt: new Date(),
589 updatedAt: collectionData.updatedAt,
590 },
591 new UniqueEntityID(),
592 ).unwrap();
593
594 await collectionRepository.save(collection);
595
596 // Add cards to match expected card count
597 for (let i = 0; i < collectionData.cardCount; i++) {
598 const card = CardFactory.create({
599 curatorId: curatorId.value,
600 cardInput: {
601 type: CardTypeEnum.NOTE,
602 text: `Card ${i} for ${collectionData.name}`,
603 },
604 }).unwrap();
605
606 await cardRepository.save(card);
607 collection.addCard(card.cardId, curatorId);
608 }
609
610 if (collectionData.cardCount > 0) {
611 await collectionRepository.save(collection);
612 }
613 }
614 });
615
616 it('should combine sorting by updated date desc with pagination', async () => {
617 // First page - should get Delta (newest) and Beta
618 const page1 = await queryRepository.findByCreator(curatorId.value, {
619 page: 1,
620 limit: 2,
621 sortBy: CollectionSortField.UPDATED_AT,
622 sortOrder: SortOrder.DESC,
623 });
624
625 expect(page1.items).toHaveLength(2);
626 expect(page1.items[0]!.updatedAt.getTime()).toBeGreaterThanOrEqual(
627 page1.items[1]!.updatedAt.getTime(),
628 );
629 expect(page1.hasMore).toBe(true);
630
631 // Second page - should get Gamma and Alpha
632 const page2 = await queryRepository.findByCreator(curatorId.value, {
633 page: 2,
634 limit: 2,
635 sortBy: CollectionSortField.UPDATED_AT,
636 sortOrder: SortOrder.DESC,
637 });
638
639 expect(page2.items).toHaveLength(2);
640 expect(page2.items[0]!.updatedAt.getTime()).toBeGreaterThanOrEqual(
641 page2.items[1]!.updatedAt.getTime(),
642 );
643 expect(page2.hasMore).toBe(false);
644 });
645
646 it('should combine sorting by card count desc with pagination', async () => {
647 // First page - should get Alpha (3 cards) and Gamma (2 cards)
648 const page1 = await queryRepository.findByCreator(curatorId.value, {
649 page: 1,
650 limit: 2,
651 sortBy: CollectionSortField.CARD_COUNT,
652 sortOrder: SortOrder.DESC,
653 });
654
655 expect(page1.items).toHaveLength(2);
656 expect(page1.items[0]?.name).toBe('Alpha');
657 expect(page1.items[0]?.cardCount).toBe(3);
658 expect(page1.items[1]?.name).toBe('Gamma');
659 expect(page1.items[1]?.cardCount).toBe(2);
660 expect(page1.hasMore).toBe(true);
661
662 // Second page - should get Beta (1 card) and Delta (0 cards)
663 const page2 = await queryRepository.findByCreator(curatorId.value, {
664 page: 2,
665 limit: 2,
666 sortBy: CollectionSortField.CARD_COUNT,
667 sortOrder: SortOrder.DESC,
668 });
669
670 expect(page2.items).toHaveLength(2);
671 expect(page2.items[0]?.name).toBe('Beta');
672 expect(page2.items[0]?.cardCount).toBe(1);
673 expect(page2.items[1]?.name).toBe('Delta');
674 expect(page2.items[1]?.cardCount).toBe(0);
675 expect(page2.hasMore).toBe(false);
676 });
677 });
678
679 describe('text search', () => {
680 beforeEach(async () => {
681 // Create collections with different names and descriptions for search testing
682 const collections = [
683 {
684 name: 'Machine Learning Papers',
685 description: 'Collection of AI and ML research papers',
686 },
687 {
688 name: 'Web Development',
689 description: 'Frontend and backend development resources',
690 },
691 {
692 name: 'Data Science',
693 description: 'Statistics, machine learning, and data analysis',
694 },
695 {
696 name: 'JavaScript Tutorials',
697 description: 'Learning resources for JS development',
698 },
699 {
700 name: 'Python Scripts',
701 description: 'Useful Python automation and data scripts',
702 },
703 {
704 name: 'No Description Collection',
705 description: undefined, // No description
706 },
707 ];
708
709 for (const collectionData of collections) {
710 const collection = Collection.create(
711 {
712 authorId: curatorId,
713 name: collectionData.name,
714 description: collectionData.description,
715 accessType: CollectionAccessType.OPEN,
716 collaboratorIds: [],
717 createdAt: new Date(),
718 updatedAt: new Date(),
719 },
720 new UniqueEntityID(),
721 ).unwrap();
722
723 await collectionRepository.save(collection);
724 }
725 });
726
727 it('should return all collections when no search text provided', async () => {
728 const result = await queryRepository.findByCreator(curatorId.value, {
729 page: 1,
730 limit: 10,
731 sortBy: CollectionSortField.NAME,
732 sortOrder: SortOrder.ASC,
733 });
734
735 expect(result.items).toHaveLength(6);
736 expect(result.totalCount).toBe(6);
737 });
738
739 it('should search by collection name (case insensitive)', async () => {
740 const result = await queryRepository.findByCreator(curatorId.value, {
741 page: 1,
742 limit: 10,
743 sortBy: CollectionSortField.NAME,
744 sortOrder: SortOrder.ASC,
745 searchText: 'MACHINE',
746 });
747
748 expect(result.items).toHaveLength(2);
749 expect(result.totalCount).toBe(2);
750 });
751
752 it('should search by collection description', async () => {
753 const result = await queryRepository.findByCreator(curatorId.value, {
754 page: 1,
755 limit: 10,
756 sortBy: CollectionSortField.NAME,
757 sortOrder: SortOrder.ASC,
758 searchText: 'development',
759 });
760
761 expect(result.items).toHaveLength(2);
762 expect(result.totalCount).toBe(2);
763
764 const names = result.items.map((item) => item.name).sort();
765 expect(names).toEqual(['JavaScript Tutorials', 'Web Development']);
766 });
767
768 it('should search across both name and description', async () => {
769 const result = await queryRepository.findByCreator(curatorId.value, {
770 page: 1,
771 limit: 10,
772 sortBy: CollectionSortField.NAME,
773 sortOrder: SortOrder.ASC,
774 searchText: 'python',
775 });
776
777 expect(result.items).toHaveLength(1);
778 expect(result.items[0]?.name).toBe('Python Scripts');
779 expect(result.totalCount).toBe(1);
780 });
781
782 it('should return multiple matches for broad search terms', async () => {
783 const result = await queryRepository.findByCreator(curatorId.value, {
784 page: 1,
785 limit: 10,
786 sortBy: CollectionSortField.NAME,
787 sortOrder: SortOrder.ASC,
788 searchText: 'learning',
789 });
790
791 expect(result.items).toHaveLength(3);
792 expect(result.totalCount).toBe(3);
793 });
794
795 it('should return empty results for non-matching search', async () => {
796 const result = await queryRepository.findByCreator(curatorId.value, {
797 page: 1,
798 limit: 10,
799 sortBy: CollectionSortField.NAME,
800 sortOrder: SortOrder.ASC,
801 searchText: 'nonexistent',
802 });
803
804 expect(result.items).toHaveLength(0);
805 expect(result.totalCount).toBe(0);
806 expect(result.hasMore).toBe(false);
807 });
808
809 it('should handle empty search text as no filter', async () => {
810 const result = await queryRepository.findByCreator(curatorId.value, {
811 page: 1,
812 limit: 10,
813 sortBy: CollectionSortField.NAME,
814 sortOrder: SortOrder.ASC,
815 searchText: '',
816 });
817
818 expect(result.items).toHaveLength(6);
819 expect(result.totalCount).toBe(6);
820 });
821
822 it('should handle whitespace-only search text as no filter', async () => {
823 const result = await queryRepository.findByCreator(curatorId.value, {
824 page: 1,
825 limit: 10,
826 sortBy: CollectionSortField.NAME,
827 sortOrder: SortOrder.ASC,
828 searchText: ' ',
829 });
830
831 expect(result.items).toHaveLength(6);
832 expect(result.totalCount).toBe(6);
833 });
834
835 it('should combine search with pagination', async () => {
836 const result = await queryRepository.findByCreator(curatorId.value, {
837 page: 1,
838 limit: 1,
839 sortBy: CollectionSortField.NAME,
840 sortOrder: SortOrder.ASC,
841 searchText: 'learning',
842 });
843
844 expect(result.items).toHaveLength(1);
845 expect(result.totalCount).toBe(3);
846 expect(result.hasMore).toBe(true);
847 });
848
849 it('should combine search with sorting by name desc', async () => {
850 const result = await queryRepository.findByCreator(curatorId.value, {
851 page: 1,
852 limit: 10,
853 sortBy: CollectionSortField.NAME,
854 sortOrder: SortOrder.DESC,
855 searchText: 'learning',
856 });
857
858 expect(result.items).toHaveLength(3);
859 });
860
861 it('should search collections with null descriptions', async () => {
862 const result = await queryRepository.findByCreator(curatorId.value, {
863 page: 1,
864 limit: 10,
865 sortBy: CollectionSortField.NAME,
866 sortOrder: SortOrder.ASC,
867 searchText: 'description',
868 });
869
870 expect(result.items).toHaveLength(1);
871 expect(result.items[0]?.name).toBe('No Description Collection');
872 expect(result.totalCount).toBe(1);
873 });
874
875 it('should handle special characters in search text', async () => {
876 // Create a collection with special characters
877 const collection = Collection.create(
878 {
879 authorId: curatorId,
880 name: 'C++ Programming',
881 description: 'Advanced C++ & system programming',
882 accessType: CollectionAccessType.OPEN,
883 collaboratorIds: [],
884 createdAt: new Date(),
885 updatedAt: new Date(),
886 },
887 new UniqueEntityID(),
888 ).unwrap();
889
890 await collectionRepository.save(collection);
891
892 const result = await queryRepository.findByCreator(curatorId.value, {
893 page: 1,
894 limit: 10,
895 sortBy: CollectionSortField.NAME,
896 sortOrder: SortOrder.ASC,
897 searchText: 'C++',
898 });
899
900 expect(result.items).toHaveLength(1);
901 expect(result.items[0]?.name).toBe('C++ Programming');
902 });
903
904 it('should handle partial word matches', async () => {
905 const result = await queryRepository.findByCreator(curatorId.value, {
906 page: 1,
907 limit: 10,
908 sortBy: CollectionSortField.NAME,
909 sortOrder: SortOrder.ASC,
910 searchText: 'script',
911 });
912
913 expect(result.items).toHaveLength(3);
914 expect(result.totalCount).toBe(3);
915 });
916
917 it('should not return collections from other curators in search', async () => {
918 // Create collection for other curator
919 const otherCollection = Collection.create(
920 {
921 authorId: otherCuratorId,
922 name: 'Other Machine Learning',
923 description: 'Another ML collection',
924 accessType: CollectionAccessType.OPEN,
925 collaboratorIds: [],
926 createdAt: new Date(),
927 updatedAt: new Date(),
928 },
929 new UniqueEntityID(),
930 ).unwrap();
931
932 await collectionRepository.save(otherCollection);
933
934 const result = await queryRepository.findByCreator(curatorId.value, {
935 page: 1,
936 limit: 10,
937 sortBy: CollectionSortField.NAME,
938 sortOrder: SortOrder.ASC,
939 searchText: 'machine',
940 });
941
942 expect(result.items).toHaveLength(2);
943 });
944 });
945
946 describe('published record URIs', () => {
947 it('should return empty string for collections without published records', async () => {
948 const collection = Collection.create(
949 {
950 authorId: curatorId,
951 name: 'Unpublished Collection',
952 accessType: CollectionAccessType.OPEN,
953 collaboratorIds: [],
954 createdAt: new Date(),
955 updatedAt: new Date(),
956 },
957 new UniqueEntityID(),
958 ).unwrap();
959
960 await collectionRepository.save(collection);
961
962 const result = await queryRepository.findByCreator(curatorId.value, {
963 page: 1,
964 limit: 10,
965 sortBy: CollectionSortField.UPDATED_AT,
966 sortOrder: SortOrder.DESC,
967 });
968
969 expect(result.items).toHaveLength(1);
970 expect(result.items[0]?.uri).toBeUndefined();
971 });
972
973 it('should return URI for collections with published records', async () => {
974 const testUri =
975 'at://did:plc:testcurator/network.cosmik.collection/test123';
976 const testCid = 'bafytest123';
977
978 // Create collection using builder
979 const collection = new CollectionBuilder()
980 .withAuthorId(curatorId.value)
981 .withName('Published Collection')
982 .withAccessType(CollectionAccessType.OPEN)
983 .buildOrThrow();
984
985 // Publish the collection using the fake publisher
986 const publishResult = await fakePublisher.publish(collection);
987 expect(publishResult.isOk()).toBe(true);
988
989 const publishedRecordId = publishResult.unwrap();
990
991 // Mark the collection as published in the domain model
992 collection.markAsPublished(publishedRecordId);
993
994 // Save the collection
995 await collectionRepository.save(collection);
996
997 const result = await queryRepository.findByCreator(curatorId.value, {
998 page: 1,
999 limit: 10,
1000 sortBy: CollectionSortField.UPDATED_AT,
1001 sortOrder: SortOrder.DESC,
1002 });
1003
1004 expect(result.items).toHaveLength(1);
1005 expect(result.items[0]?.uri).toBe(publishedRecordId.uri);
1006 expect(result.items[0]?.name).toBe('Published Collection');
1007 });
1008
1009 it('should handle mix of published and unpublished collections', async () => {
1010 // Create published collection using builder
1011 const publishedCollection = new CollectionBuilder()
1012 .withAuthorId(curatorId.value)
1013 .withName('Published Collection')
1014 .withAccessType(CollectionAccessType.OPEN)
1015 .withCreatedAt(new Date('2023-01-01'))
1016 .withUpdatedAt(new Date('2023-01-01'))
1017 .buildOrThrow();
1018
1019 // Create unpublished collection using builder
1020 const unpublishedCollection = new CollectionBuilder()
1021 .withAuthorId(curatorId.value)
1022 .withName('Unpublished Collection')
1023 .withAccessType(CollectionAccessType.OPEN)
1024 .withCreatedAt(new Date('2023-01-02'))
1025 .withUpdatedAt(new Date('2023-01-02'))
1026 .buildOrThrow();
1027
1028 // Publish the first collection using the fake publisher
1029 const publishResult = await fakePublisher.publish(publishedCollection);
1030 expect(publishResult.isOk()).toBe(true);
1031
1032 const publishedRecordId = publishResult.unwrap();
1033
1034 // Mark the collection as published in the domain model
1035 publishedCollection.markAsPublished(publishedRecordId);
1036
1037 // Save both collections
1038 await collectionRepository.save(publishedCollection);
1039 await collectionRepository.save(unpublishedCollection);
1040
1041 const result = await queryRepository.findByCreator(curatorId.value, {
1042 page: 1,
1043 limit: 10,
1044 sortBy: CollectionSortField.UPDATED_AT,
1045 sortOrder: SortOrder.DESC,
1046 });
1047
1048 expect(result.items).toHaveLength(2);
1049
1050 // Find collections by name and check URIs
1051 const publishedItem = result.items.find(
1052 (item) => item.name === 'Published Collection',
1053 );
1054 const unpublishedItem = result.items.find(
1055 (item) => item.name === 'Unpublished Collection',
1056 );
1057
1058 expect(publishedItem?.uri).toBe(publishedRecordId.uri);
1059 expect(unpublishedItem?.uri).toBeUndefined();
1060 });
1061 });
1062
1063 describe('edge cases', () => {
1064 it('should handle curator with no collections gracefully', async () => {
1065 const result = await queryRepository.findByCreator(
1066 'did:plc:nonexistent',
1067 {
1068 page: 1,
1069 limit: 10,
1070 sortBy: CollectionSortField.UPDATED_AT,
1071 sortOrder: SortOrder.DESC,
1072 },
1073 );
1074
1075 expect(result.items).toHaveLength(0);
1076 expect(result.totalCount).toBe(0);
1077 expect(result.hasMore).toBe(false);
1078 });
1079
1080 it('should handle collections with null descriptions', async () => {
1081 const collection = Collection.create(
1082 {
1083 authorId: curatorId,
1084 name: 'No Description Collection',
1085 // No description provided
1086 accessType: CollectionAccessType.OPEN,
1087 collaboratorIds: [],
1088 createdAt: new Date(),
1089 updatedAt: new Date(),
1090 },
1091 new UniqueEntityID(),
1092 ).unwrap();
1093
1094 await collectionRepository.save(collection);
1095
1096 const result = await queryRepository.findByCreator(curatorId.value, {
1097 page: 1,
1098 limit: 10,
1099 sortBy: CollectionSortField.UPDATED_AT,
1100 sortOrder: SortOrder.DESC,
1101 });
1102
1103 expect(result.items).toHaveLength(1);
1104 expect(result.items[0]?.description).toBeUndefined();
1105 });
1106
1107 it('should handle very large page numbers', async () => {
1108 const collection = Collection.create(
1109 {
1110 authorId: curatorId,
1111 name: 'Single Collection',
1112 accessType: CollectionAccessType.OPEN,
1113 collaboratorIds: [],
1114 createdAt: new Date(),
1115 updatedAt: new Date(),
1116 },
1117 new UniqueEntityID(),
1118 ).unwrap();
1119
1120 await collectionRepository.save(collection);
1121
1122 const result = await queryRepository.findByCreator(curatorId.value, {
1123 page: 999999,
1124 limit: 10,
1125 sortBy: CollectionSortField.UPDATED_AT,
1126 sortOrder: SortOrder.DESC,
1127 });
1128
1129 expect(result.items).toHaveLength(0);
1130 expect(result.totalCount).toBe(1);
1131 expect(result.hasMore).toBe(false);
1132 });
1133 });
1134});