This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

feat: update use case tests to handle open and closed collection access control scenarios

Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) <aider@aider.chat>

+648 -18
+15 -2
.agent/logs/20260121_feature_implementation_guide.md
··· 5 5 ## Overview 6 6 7 7 Each feature follows a consistent layered architecture: 8 + 8 9 - **API Client** - TypeScript types and client methods 9 10 - **HTTP Layer** - Controllers and routes 10 11 - **Application Layer** - Use cases (business logic) ··· 18 19 Start by defining the request/response types that will be used across the API: 19 20 20 21 **Request Types** (`requests.ts`): 22 + 21 23 ```typescript 22 24 export interface MyFeatureRequest { 23 25 param1: string; ··· 26 28 ``` 27 29 28 30 **Response Types** (`responses.ts`): 31 + 29 32 ```typescript 30 33 export interface MyFeatureResponse { 31 34 id: string; ··· 68 71 69 72 async executeImpl(req: AuthenticatedRequest, res: Response): Promise<any> { 70 73 const result = await this.useCase.execute(req.body); 71 - 74 + 72 75 if (result.isErr()) { 73 76 return this.fail(res, result.error); 74 77 } 75 - 78 + 76 79 return this.ok(res, result.value); 77 80 } 78 81 } ··· 91 94 ### 5. Wire Dependencies (`src/shared/infrastructure/http/factories/`) 92 95 93 96 **UseCaseFactory.ts** - Add use case instantiation: 97 + 94 98 ```typescript 95 99 myFeatureUseCase: new MyFeatureUseCase( 96 100 repositories.myRepository, ··· 100 104 ``` 101 105 102 106 **ControllerFactory.ts** - Add controller instantiation: 107 + 103 108 ```typescript 104 109 myFeatureController: new MyFeatureController( 105 110 useCases.myFeatureUseCase, ··· 109 114 ### 6. Add API Client Method (`src/webapp/api-client/`) 110 115 111 116 **Client Class** (`clients/MyClient.ts`): 117 + 112 118 ```typescript 113 119 export class MyClient extends BaseClient { 114 120 async myFeature(request: MyFeatureRequest): Promise<MyFeatureResponse> { ··· 118 124 ``` 119 125 120 126 **Main API Client** (`ApiClient.ts`): 127 + 121 128 ```typescript 122 129 async myFeature(request: MyFeatureRequest): Promise<MyFeatureResponse> { 123 130 return this.myClient.myFeature(request); ··· 127 134 ## Key Patterns 128 135 129 136 ### Repository Pattern 137 + 130 138 - Interface in domain layer (`src/modules/{module}/domain/`) 131 139 - Implementation in infrastructure layer (`src/modules/{module}/infrastructure/repositories/`) 132 140 - Both real (Drizzle) and in-memory test implementations 133 141 134 142 ### Domain Services 143 + 135 144 - Business logic that doesn't belong to a single entity 136 145 - Located in `src/modules/{module}/domain/services/` 137 146 - Injected into use cases 138 147 139 148 ### Value Objects 149 + 140 150 - Immutable objects representing domain concepts 141 151 - Located in `src/modules/{module}/domain/value-objects/` 142 152 - Include validation logic 143 153 144 154 ### Error Handling 155 + 145 156 - Use `Result<T, E>` pattern for error handling 146 157 - Custom error types extend `UseCaseError` 147 158 - Controllers handle different error types appropriately 148 159 149 160 ### Authentication 161 + 150 162 - Use `AuthMiddleware.ensureAuthenticated()` for protected routes 151 163 - Use `AuthMiddleware.optionalAuth()` for routes that work with/without auth 152 164 - Access user DID via `req.did` in controllers 153 165 154 166 ### Event Publishing 167 + 155 168 - Use cases extend `BaseUseCase` for event publishing 156 169 - Call `this.publishEventsForAggregate(entity)` after operations 157 170 - Events are handled by separate worker processes
+192 -3
src/modules/cards/tests/application/AddUrlToLibraryUseCase.test.ts
··· 318 318 }); 319 319 320 320 describe('Collection handling', () => { 321 - it('should add URL card to specified collections', async () => { 322 - // Create a test collection 321 + it('should add URL card to open collections', async () => { 322 + // Create an open test collection 323 323 const collection = new CollectionBuilder() 324 324 .withAuthorId(curatorId.value) 325 - .withName('Test Collection') 325 + .withName('Open Test Collection') 326 + .withAccessType('OPEN') 326 327 .build(); 327 328 328 329 if (collection instanceof Error) { ··· 365 366 expect(collectionEvents[0]?.addedBy.equals(curatorId)).toBe(true); 366 367 }); 367 368 369 + it('should add URL card to closed collections when user is the author', async () => { 370 + // Create a closed test collection owned by the curator 371 + const collection = new CollectionBuilder() 372 + .withAuthorId(curatorId.value) 373 + .withName('Closed Test Collection') 374 + .withAccessType('CLOSED') 375 + .build(); 376 + 377 + if (collection instanceof Error) { 378 + throw new Error(`Failed to create collection: ${collection.message}`); 379 + } 380 + 381 + await collectionRepository.save(collection); 382 + 383 + const request = { 384 + url: 'https://example.com/article', 385 + collectionIds: [collection.collectionId.getStringValue()], 386 + curatorId: curatorId.value, 387 + }; 388 + 389 + const result = await useCase.execute(request); 390 + 391 + expect(result.isOk()).toBe(true); 392 + 393 + // Verify collection link was published 394 + const publishedLinks = collectionPublisher.getPublishedLinksForCollection( 395 + collection.collectionId.getStringValue(), 396 + ); 397 + expect(publishedLinks).toHaveLength(1); 398 + }); 399 + 400 + it('should add URL card to closed collections when user is a collaborator', async () => { 401 + const otherCuratorId = CuratorId.create('did:plc:othercurator').unwrap(); 402 + 403 + // Create a closed test collection with curator as collaborator 404 + const collection = new CollectionBuilder() 405 + .withAuthorId(otherCuratorId.value) 406 + .withName('Closed Collaborative Collection') 407 + .withAccessType('CLOSED') 408 + .withCollaborators([curatorId.value]) 409 + .build(); 410 + 411 + if (collection instanceof Error) { 412 + throw new Error(`Failed to create collection: ${collection.message}`); 413 + } 414 + 415 + await collectionRepository.save(collection); 416 + 417 + const request = { 418 + url: 'https://example.com/article', 419 + collectionIds: [collection.collectionId.getStringValue()], 420 + curatorId: curatorId.value, 421 + }; 422 + 423 + const result = await useCase.execute(request); 424 + 425 + expect(result.isOk()).toBe(true); 426 + 427 + // Verify collection link was published 428 + const publishedLinks = collectionPublisher.getPublishedLinksForCollection( 429 + collection.collectionId.getStringValue(), 430 + ); 431 + expect(publishedLinks).toHaveLength(1); 432 + }); 433 + 434 + it('should fail to add URL card to closed collections when user lacks permission', async () => { 435 + const otherCuratorId = CuratorId.create('did:plc:othercurator').unwrap(); 436 + 437 + // Create a closed test collection owned by someone else 438 + const collection = new CollectionBuilder() 439 + .withAuthorId(otherCuratorId.value) 440 + .withName('Private Closed Collection') 441 + .withAccessType('CLOSED') 442 + .build(); 443 + 444 + if (collection instanceof Error) { 445 + throw new Error(`Failed to create collection: ${collection.message}`); 446 + } 447 + 448 + await collectionRepository.save(collection); 449 + 450 + const request = { 451 + url: 'https://example.com/article', 452 + collectionIds: [collection.collectionId.getStringValue()], 453 + curatorId: curatorId.value, 454 + }; 455 + 456 + const result = await useCase.execute(request); 457 + 458 + expect(result.isErr()).toBe(true); 459 + if (result.isErr()) { 460 + expect(result.error.message).toContain('does not have permission'); 461 + } 462 + 463 + // Verify no collection link was published 464 + const publishedLinks = collectionPublisher.getPublishedLinksForCollection( 465 + collection.collectionId.getStringValue(), 466 + ); 467 + expect(publishedLinks).toHaveLength(0); 468 + }); 469 + 470 + it('should add URL card to open collections owned by other users', async () => { 471 + const otherCuratorId = CuratorId.create('did:plc:othercurator').unwrap(); 472 + 473 + // Create an open test collection owned by someone else 474 + const collection = new CollectionBuilder() 475 + .withAuthorId(otherCuratorId.value) 476 + .withName('Public Open Collection') 477 + .withAccessType('OPEN') 478 + .build(); 479 + 480 + if (collection instanceof Error) { 481 + throw new Error(`Failed to create collection: ${collection.message}`); 482 + } 483 + 484 + await collectionRepository.save(collection); 485 + 486 + const request = { 487 + url: 'https://example.com/article', 488 + collectionIds: [collection.collectionId.getStringValue()], 489 + curatorId: curatorId.value, 490 + }; 491 + 492 + const result = await useCase.execute(request); 493 + 494 + expect(result.isOk()).toBe(true); 495 + 496 + // Verify collection link was published 497 + const publishedLinks = collectionPublisher.getPublishedLinksForCollection( 498 + collection.collectionId.getStringValue(), 499 + ); 500 + expect(publishedLinks).toHaveLength(1); 501 + }); 502 + 368 503 it('should add URL card to collections with viaCardId', async () => { 369 504 // Create a test collection 370 505 const collection = new CollectionBuilder() ··· 520 655 if (result.isErr()) { 521 656 expect(result.error.message).toContain('Collection not found'); 522 657 } 658 + }); 659 + 660 + it('should handle mixed collection permissions correctly', async () => { 661 + const otherCuratorId = CuratorId.create('did:plc:othercurator').unwrap(); 662 + 663 + // Create an open collection and a closed collection without permission 664 + const openCollection = new CollectionBuilder() 665 + .withAuthorId(otherCuratorId.value) 666 + .withName('Open Collection') 667 + .withAccessType('OPEN') 668 + .build(); 669 + 670 + const closedCollection = new CollectionBuilder() 671 + .withAuthorId(otherCuratorId.value) 672 + .withName('Closed Collection') 673 + .withAccessType('CLOSED') 674 + .build(); 675 + 676 + if ( 677 + openCollection instanceof Error || 678 + closedCollection instanceof Error 679 + ) { 680 + throw new Error('Failed to create collections'); 681 + } 682 + 683 + await collectionRepository.save(openCollection); 684 + await collectionRepository.save(closedCollection); 685 + 686 + const request = { 687 + url: 'https://example.com/article', 688 + collectionIds: [ 689 + openCollection.collectionId.getStringValue(), 690 + closedCollection.collectionId.getStringValue(), 691 + ], 692 + curatorId: curatorId.value, 693 + }; 694 + 695 + const result = await useCase.execute(request); 696 + 697 + expect(result.isErr()).toBe(true); 698 + if (result.isErr()) { 699 + expect(result.error.message).toContain('does not have permission'); 700 + } 701 + 702 + const openPublishedLinks = 703 + collectionPublisher.getPublishedLinksForCollection( 704 + openCollection.collectionId.getStringValue(), 705 + ); 706 + const closedPublishedLinks = 707 + collectionPublisher.getPublishedLinksForCollection( 708 + closedCollection.collectionId.getStringValue(), 709 + ); 710 + expect(openPublishedLinks).toHaveLength(1); 711 + expect(closedPublishedLinks).toHaveLength(0); 523 712 }); 524 713 }); 525 714
+161 -6
src/modules/cards/tests/application/RemoveCardFromCollectionUseCase.test.ts
··· 214 214 } 215 215 }); 216 216 217 - it('should allow removal from open collection by any user', async () => { 217 + it('should allow removal from open collection by collection author (can remove any card)', async () => { 218 + const card = await createCard(); 219 + const collection = new CollectionBuilder() 220 + .withAuthorId(curatorId.value) 221 + .withName('Open Collection') 222 + .withAccessType('OPEN') 223 + .withPublished(true) 224 + .build(); 225 + 226 + if (collection instanceof Error) { 227 + throw new Error(`Failed to create collection: ${collection.message}`); 228 + } 229 + 230 + await collectionRepository.save(collection); 231 + 232 + // Add card to collection (as collection owner) 233 + await addCardToCollection(card, collection, curatorId); 234 + 235 + const request = { 236 + cardId: card.cardId.getStringValue(), 237 + collectionIds: [collection.collectionId.getStringValue()], 238 + curatorId: curatorId.value, 239 + }; 240 + 241 + const result = await useCase.execute(request); 242 + 243 + expect(result.isOk()).toBe(true); 244 + 245 + // Verify card was removed from collection 246 + const removedLinks = collectionPublisher.getRemovedLinksForCollection( 247 + collection.collectionId.getStringValue(), 248 + ); 249 + expect(removedLinks).toHaveLength(1); 250 + expect(removedLinks[0]?.cardId).toBe(card.cardId.getStringValue()); 251 + }); 252 + 253 + it('should allow removal from open collection by non-author (can remove any card)', async () => { 218 254 const card = await createCard(); 219 255 const collection = new CollectionBuilder() 220 256 .withAuthorId(otherCuratorId.value) ··· 241 277 const result = await useCase.execute(request); 242 278 243 279 expect(result.isOk()).toBe(true); 280 + 281 + // Verify card was removed from collection 282 + const removedLinks = collectionPublisher.getRemovedLinksForCollection( 283 + collection.collectionId.getStringValue(), 284 + ); 285 + expect(removedLinks).toHaveLength(1); 286 + expect(removedLinks[0]?.cardId).toBe(card.cardId.getStringValue()); 244 287 }); 245 288 246 - it('should allow collection author to remove any card', async () => { 289 + it('should allow collection author to remove any card from closed collection', async () => { 247 290 const card = await createCard(); 248 - const collection = await createCollection( 249 - curatorId, 250 - "Author's Collection", 291 + const collection = new CollectionBuilder() 292 + .withAuthorId(curatorId.value) 293 + .withName('Closed Collection') 294 + .withAccessType('CLOSED') 295 + .withPublished(true) 296 + .build(); 297 + 298 + if (collection instanceof Error) { 299 + throw new Error(`Failed to create collection: ${collection.message}`); 300 + } 301 + 302 + await collectionRepository.save(collection); 303 + 304 + await addCardToCollection(card, collection, curatorId); 305 + 306 + const request = { 307 + cardId: card.cardId.getStringValue(), 308 + collectionIds: [collection.collectionId.getStringValue()], 309 + curatorId: curatorId.value, 310 + }; 311 + 312 + const result = await useCase.execute(request); 313 + 314 + expect(result.isOk()).toBe(true); 315 + 316 + // Verify card was removed from collection 317 + const removedLinks = collectionPublisher.getRemovedLinksForCollection( 318 + collection.collectionId.getStringValue(), 251 319 ); 320 + expect(removedLinks).toHaveLength(1); 321 + expect(removedLinks[0]?.cardId).toBe(card.cardId.getStringValue()); 322 + }); 252 323 253 - await addCardToCollection(card, collection, curatorId); 324 + it('should allow collaborator to remove cards from closed collection', async () => { 325 + const card = await createCard(); 326 + const collection = new CollectionBuilder() 327 + .withAuthorId(otherCuratorId.value) 328 + .withName('Closed Collaborative Collection') 329 + .withAccessType('CLOSED') 330 + .withCollaborators([curatorId.value]) 331 + .withPublished(true) 332 + .build(); 333 + 334 + if (collection instanceof Error) { 335 + throw new Error(`Failed to create collection: ${collection.message}`); 336 + } 337 + 338 + await collectionRepository.save(collection); 339 + 340 + // Add card to collection first (as collection owner) 341 + await addCardToCollection(card, collection, otherCuratorId); 254 342 255 343 const request = { 256 344 cardId: card.cardId.getStringValue(), ··· 261 349 const result = await useCase.execute(request); 262 350 263 351 expect(result.isOk()).toBe(true); 352 + 353 + // Verify card was removed from collection 354 + const removedLinks = collectionPublisher.getRemovedLinksForCollection( 355 + collection.collectionId.getStringValue(), 356 + ); 357 + expect(removedLinks).toHaveLength(1); 358 + expect(removedLinks[0]?.cardId).toBe(card.cardId.getStringValue()); 359 + }); 360 + 361 + it('should handle mixed collection permissions when removing cards', async () => { 362 + const card = await createCard(); 363 + 364 + // Create an open collection and a closed collection without permission 365 + const openCollection = new CollectionBuilder() 366 + .withAuthorId(otherCuratorId.value) 367 + .withName('Open Collection') 368 + .withAccessType('OPEN') 369 + .withPublished(true) 370 + .build(); 371 + 372 + const closedCollection = new CollectionBuilder() 373 + .withAuthorId(otherCuratorId.value) 374 + .withName('Closed Collection') 375 + .withAccessType('CLOSED') 376 + .withPublished(true) 377 + .build(); 378 + 379 + if ( 380 + openCollection instanceof Error || 381 + closedCollection instanceof Error 382 + ) { 383 + throw new Error('Failed to create collections'); 384 + } 385 + 386 + await collectionRepository.save(openCollection); 387 + await collectionRepository.save(closedCollection); 388 + 389 + // Add card to both collections (as collection owner) 390 + await addCardToCollection(card, openCollection, otherCuratorId); 391 + await addCardToCollection(card, closedCollection, otherCuratorId); 392 + 393 + const request = { 394 + cardId: card.cardId.getStringValue(), 395 + collectionIds: [ 396 + openCollection.collectionId.getStringValue(), 397 + closedCollection.collectionId.getStringValue(), 398 + ], 399 + curatorId: curatorId.value, 400 + }; 401 + 402 + const result = await useCase.execute(request); 403 + 404 + expect(result.isErr()).toBe(true); 405 + if (result.isErr()) { 406 + expect(result.error.message).toContain('does not have permission'); 407 + } 408 + 409 + // Verify no cards were removed from either collection 410 + const openRemovedLinks = collectionPublisher.getRemovedLinksForCollection( 411 + openCollection.collectionId.getStringValue(), 412 + ); 413 + const closedRemovedLinks = 414 + collectionPublisher.getRemovedLinksForCollection( 415 + closedCollection.collectionId.getStringValue(), 416 + ); 417 + expect(openRemovedLinks).toHaveLength(0); 418 + expect(closedRemovedLinks).toHaveLength(0); 264 419 }); 265 420 }); 266 421
+280 -7
src/modules/cards/tests/application/UpdateUrlCardAssociationsUseCase.test.ts
··· 11 11 import { CollectionBuilder } from '../utils/builders/CollectionBuilder'; 12 12 import { CardTypeEnum } from '../../domain/value-objects/CardType'; 13 13 import { FakeEventPublisher } from '../utils/FakeEventPublisher'; 14 + import { CardId } from '../../domain/value-objects/CardId'; 14 15 15 16 describe('UpdateUrlCardAssociationsUseCase', () => { 16 17 let useCase: UpdateUrlCardAssociationsUseCase; ··· 159 160 }); 160 161 161 162 describe('Collection management', () => { 162 - it('should add URL card to collections', async () => { 163 + it('should add URL card to open collections', async () => { 163 164 const url = 'https://example.com/article'; 164 165 165 - // Create collections 166 + // Create open collections 166 167 const collection1 = new CollectionBuilder() 167 168 .withAuthorId(curatorId.value) 168 - .withName('Collection 1') 169 + .withName('Open Collection 1') 170 + .withAccessType('OPEN') 169 171 .build(); 170 172 const collection2 = new CollectionBuilder() 171 173 .withAuthorId(curatorId.value) 172 - .withName('Collection 2') 174 + .withName('Open Collection 2') 175 + .withAccessType('OPEN') 173 176 .build(); 174 177 175 178 if (collection1 instanceof Error || collection2 instanceof Error) { ··· 210 213 ); 211 214 }); 212 215 216 + it('should add URL card to closed collections when user is the author', async () => { 217 + const url = 'https://example.com/article'; 218 + 219 + // Create closed collection owned by the curator 220 + const collection = new CollectionBuilder() 221 + .withAuthorId(curatorId.value) 222 + .withName('Closed Collection') 223 + .withAccessType('CLOSED') 224 + .build(); 225 + 226 + if (collection instanceof Error) { 227 + throw new Error('Failed to create collection'); 228 + } 229 + 230 + await collectionRepository.save(collection); 231 + 232 + // Add URL to library 233 + const addResult = await addUrlToLibraryUseCase.execute({ 234 + url, 235 + curatorId: curatorId.value, 236 + }); 237 + expect(addResult.isOk()).toBe(true); 238 + const urlCardId = addResult.unwrap().urlCardId; 239 + 240 + // Add to collection 241 + const updateRequest = { 242 + cardId: urlCardId, 243 + curatorId: curatorId.value, 244 + addToCollections: [collection.collectionId.getStringValue()], 245 + }; 246 + 247 + const result = await useCase.execute(updateRequest); 248 + 249 + expect(result.isOk()).toBe(true); 250 + const response = result.unwrap(); 251 + expect(response.addedToCollections).toHaveLength(1); 252 + expect(response.addedToCollections).toContain( 253 + collection.collectionId.getStringValue(), 254 + ); 255 + }); 256 + 257 + it('should fail to add URL card to closed collections when user lacks permission', async () => { 258 + const url = 'https://example.com/article'; 259 + const otherCuratorId = CuratorId.create('did:plc:othercurator').unwrap(); 260 + 261 + // Create closed collection owned by someone else 262 + const collection = new CollectionBuilder() 263 + .withAuthorId(otherCuratorId.value) 264 + .withName('Private Closed Collection') 265 + .withAccessType('CLOSED') 266 + .build(); 267 + 268 + if (collection instanceof Error) { 269 + throw new Error('Failed to create collection'); 270 + } 271 + 272 + await collectionRepository.save(collection); 273 + 274 + // Add URL to library 275 + const addResult = await addUrlToLibraryUseCase.execute({ 276 + url, 277 + curatorId: curatorId.value, 278 + }); 279 + expect(addResult.isOk()).toBe(true); 280 + const urlCardId = addResult.unwrap().urlCardId; 281 + 282 + // Try to add to collection 283 + const updateRequest = { 284 + cardId: urlCardId, 285 + curatorId: curatorId.value, 286 + addToCollections: [collection.collectionId.getStringValue()], 287 + }; 288 + 289 + const result = await useCase.execute(updateRequest); 290 + 291 + expect(result.isErr()).toBe(true); 292 + if (result.isErr()) { 293 + expect(result.error.message).toContain('does not have permission'); 294 + } 295 + }); 296 + 297 + it('should add URL card to open collections owned by other users', async () => { 298 + const url = 'https://example.com/article'; 299 + const otherCuratorId = CuratorId.create('did:plc:othercurator').unwrap(); 300 + 301 + // Create open collection owned by someone else 302 + const collection = new CollectionBuilder() 303 + .withAuthorId(otherCuratorId.value) 304 + .withName('Public Open Collection') 305 + .withAccessType('OPEN') 306 + .build(); 307 + 308 + if (collection instanceof Error) { 309 + throw new Error('Failed to create collection'); 310 + } 311 + 312 + await collectionRepository.save(collection); 313 + 314 + // Add URL to library 315 + const addResult = await addUrlToLibraryUseCase.execute({ 316 + url, 317 + curatorId: curatorId.value, 318 + }); 319 + expect(addResult.isOk()).toBe(true); 320 + const urlCardId = addResult.unwrap().urlCardId; 321 + 322 + // Add to collection 323 + const updateRequest = { 324 + cardId: urlCardId, 325 + curatorId: curatorId.value, 326 + addToCollections: [collection.collectionId.getStringValue()], 327 + }; 328 + 329 + const result = await useCase.execute(updateRequest); 330 + 331 + expect(result.isOk()).toBe(true); 332 + const response = result.unwrap(); 333 + expect(response.addedToCollections).toHaveLength(1); 334 + expect(response.addedToCollections).toContain( 335 + collection.collectionId.getStringValue(), 336 + ); 337 + }); 338 + 213 339 it('should add URL card to collections with viaCardId', async () => { 214 340 const url = 'https://example.com/article'; 215 341 ··· 269 395 expect(publishedLink?.cardId).toBe(urlCardId); 270 396 }); 271 397 272 - it('should remove URL card from collections', async () => { 398 + it('should remove URL card from open collections', async () => { 399 + const url = 'https://example.com/article'; 400 + 401 + // Create open collection 402 + const collection = new CollectionBuilder() 403 + .withAuthorId(curatorId.value) 404 + .withName('Open Test Collection') 405 + .withAccessType('OPEN') 406 + .build(); 407 + 408 + if (collection instanceof Error) { 409 + throw new Error('Failed to create collection'); 410 + } 411 + 412 + await collectionRepository.save(collection); 413 + 414 + // Add URL to library and collection 415 + const addResult = await addUrlToLibraryUseCase.execute({ 416 + url, 417 + collectionIds: [collection.collectionId.getStringValue()], 418 + curatorId: curatorId.value, 419 + }); 420 + expect(addResult.isOk()).toBe(true); 421 + const urlCardId = addResult.unwrap().urlCardId; 422 + 423 + // Remove from collection 424 + const updateRequest = { 425 + cardId: urlCardId, 426 + curatorId: curatorId.value, 427 + removeFromCollections: [collection.collectionId.getStringValue()], 428 + }; 429 + 430 + const result = await useCase.execute(updateRequest); 431 + 432 + expect(result.isOk()).toBe(true); 433 + const response = result.unwrap(); 434 + expect(response.removedFromCollections).toHaveLength(1); 435 + expect(response.removedFromCollections).toContain( 436 + collection.collectionId.getStringValue(), 437 + ); 438 + }); 439 + 440 + it('should remove URL card from closed collections when user is the author', async () => { 273 441 const url = 'https://example.com/article'; 274 442 275 - // Create collection 443 + // Create closed collection owned by the curator 276 444 const collection = new CollectionBuilder() 277 445 .withAuthorId(curatorId.value) 278 - .withName('Test Collection') 446 + .withName('Closed Test Collection') 447 + .withAccessType('CLOSED') 279 448 .build(); 280 449 281 450 if (collection instanceof Error) { ··· 292 461 }); 293 462 expect(addResult.isOk()).toBe(true); 294 463 const urlCardId = addResult.unwrap().urlCardId; 464 + 465 + // Remove from collection 466 + const updateRequest = { 467 + cardId: urlCardId, 468 + curatorId: curatorId.value, 469 + removeFromCollections: [collection.collectionId.getStringValue()], 470 + }; 471 + 472 + const result = await useCase.execute(updateRequest); 473 + 474 + expect(result.isOk()).toBe(true); 475 + const response = result.unwrap(); 476 + expect(response.removedFromCollections).toHaveLength(1); 477 + expect(response.removedFromCollections).toContain( 478 + collection.collectionId.getStringValue(), 479 + ); 480 + }); 481 + 482 + it('should fail to remove URL card from closed collections when user lacks permission', async () => { 483 + const url = 'https://example.com/article'; 484 + const otherCuratorId = CuratorId.create('did:plc:othercurator').unwrap(); 485 + 486 + // Create closed collection owned by someone else 487 + const collection = new CollectionBuilder() 488 + .withAuthorId(otherCuratorId.value) 489 + .withName('Private Closed Collection') 490 + .withAccessType('CLOSED') 491 + .build(); 492 + 493 + if (collection instanceof Error) { 494 + throw new Error('Failed to create collection'); 495 + } 496 + 497 + await collectionRepository.save(collection); 498 + 499 + // Add URL to library first 500 + const addResult = await addUrlToLibraryUseCase.execute({ 501 + url, 502 + curatorId: curatorId.value, 503 + }); 504 + expect(addResult.isOk()).toBe(true); 505 + const urlCardId = addResult.unwrap().urlCardId; 506 + 507 + // Manually add card to collection (as collection owner) to simulate existing state 508 + const cardResult = await cardRepository.findById( 509 + CardId.createFromString(urlCardId).unwrap(), 510 + ); 511 + expect(cardResult.isOk()).toBe(true); 512 + const card = cardResult.unwrap()!; 513 + 514 + const addCardResult = collection.addCard(card.cardId, otherCuratorId); 515 + expect(addCardResult.isOk()).toBe(true); 516 + await collectionRepository.save(collection); 517 + 518 + // Try to remove from collection 519 + const updateRequest = { 520 + cardId: urlCardId, 521 + curatorId: curatorId.value, 522 + removeFromCollections: [collection.collectionId.getStringValue()], 523 + }; 524 + 525 + const result = await useCase.execute(updateRequest); 526 + 527 + expect(result.isErr()).toBe(true); 528 + if (result.isErr()) { 529 + expect(result.error.message).toContain('does not have permission'); 530 + } 531 + }); 532 + 533 + it('should remove URL card from open collections owned by other users', async () => { 534 + const url = 'https://example.com/article'; 535 + const otherCuratorId = CuratorId.create('did:plc:othercurator').unwrap(); 536 + 537 + // Create open collection owned by someone else 538 + const collection = new CollectionBuilder() 539 + .withAuthorId(otherCuratorId.value) 540 + .withName('Public Open Collection') 541 + .withAccessType('OPEN') 542 + .build(); 543 + 544 + if (collection instanceof Error) { 545 + throw new Error('Failed to create collection'); 546 + } 547 + 548 + await collectionRepository.save(collection); 549 + 550 + // Add URL to library first 551 + const addResult = await addUrlToLibraryUseCase.execute({ 552 + url, 553 + curatorId: curatorId.value, 554 + }); 555 + expect(addResult.isOk()).toBe(true); 556 + const urlCardId = addResult.unwrap().urlCardId; 557 + 558 + // Manually add card to collection (as collection owner) to simulate existing state 559 + const cardResult = await cardRepository.findById( 560 + CardId.createFromString(urlCardId).unwrap(), 561 + ); 562 + expect(cardResult.isOk()).toBe(true); 563 + const card = cardResult.unwrap()!; 564 + 565 + const addCardResult = collection.addCard(card.cardId, otherCuratorId); 566 + expect(addCardResult.isOk()).toBe(true); 567 + await collectionRepository.save(collection); 295 568 296 569 // Remove from collection 297 570 const updateRequest = {