A social knowledge tool for researchers built on ATProto
1import { UpdateCollectionUseCase } from '../../application/useCases/commands/UpdateCollectionUseCase'; 2import { InMemoryCollectionRepository } from '../utils/InMemoryCollectionRepository'; 3import { FakeCollectionPublisher } from '../utils/FakeCollectionPublisher'; 4import { CuratorId } from '../../domain/value-objects/CuratorId'; 5import { CollectionBuilder } from '../utils/builders/CollectionBuilder'; 6import { Err } from 'src/shared/core/Result'; 7 8describe('UpdateCollectionUseCase', () => { 9 let useCase: UpdateCollectionUseCase; 10 let collectionRepository: InMemoryCollectionRepository; 11 let collectionPublisher: FakeCollectionPublisher; 12 let curatorId: CuratorId; 13 let otherCuratorId: CuratorId; 14 15 beforeEach(() => { 16 collectionRepository = new InMemoryCollectionRepository(); 17 collectionPublisher = new FakeCollectionPublisher(); 18 useCase = new UpdateCollectionUseCase( 19 collectionRepository, 20 collectionPublisher, 21 ); 22 23 curatorId = CuratorId.create('did:plc:testcurator').unwrap(); 24 otherCuratorId = CuratorId.create('did:plc:othercurator').unwrap(); 25 }); 26 27 afterEach(() => { 28 collectionRepository.clear(); 29 collectionPublisher.clear(); 30 }); 31 32 const createCollection = async ( 33 authorId: CuratorId, 34 name: string, 35 description?: string, 36 ) => { 37 const collection = new CollectionBuilder() 38 .withAuthorId(authorId.value) 39 .withName(name) 40 .withDescription(description || '') 41 .withPublished(true) 42 .build(); 43 44 if (collection instanceof Error) { 45 throw new Error(`Failed to create collection: ${collection.message}`); 46 } 47 48 await collectionRepository.save(collection); 49 return collection; 50 }; 51 52 describe('Basic collection update', () => { 53 it('should successfully update collection name and description', async () => { 54 const collection = await createCollection( 55 curatorId, 56 'Original Name', 57 'Original description', 58 ); 59 60 const request = { 61 collectionId: collection.collectionId.getStringValue(), 62 name: 'Updated Name', 63 description: 'Updated description', 64 curatorId: curatorId.value, 65 }; 66 67 const result = await useCase.execute(request); 68 69 expect(result.isOk()).toBe(true); 70 const response = result.unwrap(); 71 expect(response.collectionId).toBe( 72 collection.collectionId.getStringValue(), 73 ); 74 75 // Verify collection was updated 76 const updatedCollectionResult = await collectionRepository.findById( 77 collection.collectionId, 78 ); 79 const updatedCollection = updatedCollectionResult.unwrap()!; 80 expect(updatedCollection.name.value).toBe('Updated Name'); 81 expect(updatedCollection.description?.value).toBe('Updated description'); 82 expect(updatedCollection.updatedAt.getTime()).toBeGreaterThanOrEqual( 83 collection.createdAt.getTime(), 84 ); 85 }); 86 87 it('should update collection name only', async () => { 88 const collection = await createCollection( 89 curatorId, 90 'Original Name', 91 'Original description', 92 ); 93 94 const request = { 95 collectionId: collection.collectionId.getStringValue(), 96 name: 'New Name Only', 97 description: 'Original description', // Keep original description 98 curatorId: curatorId.value, 99 }; 100 101 const result = await useCase.execute(request); 102 103 expect(result.isOk()).toBe(true); 104 105 // Verify only name was updated 106 const updatedCollectionResult = await collectionRepository.findById( 107 collection.collectionId, 108 ); 109 const updatedCollection = updatedCollectionResult.unwrap()!; 110 expect(updatedCollection.name.value).toBe('New Name Only'); 111 expect(updatedCollection.description?.value).toBe('Original description'); 112 }); 113 114 it('should clear description when not provided', async () => { 115 const collection = await createCollection( 116 curatorId, 117 'Original Name', 118 'Original description', 119 ); 120 121 const request = { 122 collectionId: collection.collectionId.getStringValue(), 123 name: 'Updated Name', 124 curatorId: curatorId.value, 125 }; 126 127 const result = await useCase.execute(request); 128 129 expect(result.isOk()).toBe(true); 130 131 // Verify description was cleared 132 const updatedCollectionResult = await collectionRepository.findById( 133 collection.collectionId, 134 ); 135 const updatedCollection = updatedCollectionResult.unwrap()!; 136 expect(updatedCollection.name.value).toBe('Updated Name'); 137 expect(updatedCollection.description).toBeUndefined(); 138 }); 139 140 it('should preserve other collection properties', async () => { 141 const collection = await createCollection( 142 curatorId, 143 'Original Name', 144 'Original description', 145 ); 146 const originalCreatedAt = collection.createdAt; 147 const originalAccessType = collection.accessType; 148 149 const request = { 150 collectionId: collection.collectionId.getStringValue(), 151 name: 'Updated Name', 152 description: 'Updated description', 153 curatorId: curatorId.value, 154 }; 155 156 const result = await useCase.execute(request); 157 158 expect(result.isOk()).toBe(true); 159 160 // Verify other properties are preserved 161 const updatedCollectionResult = await collectionRepository.findById( 162 collection.collectionId, 163 ); 164 const updatedCollection = updatedCollectionResult.unwrap()!; 165 expect(updatedCollection.collectionId.getStringValue()).toBe( 166 collection.collectionId.getStringValue(), 167 ); 168 expect(updatedCollection.authorId.equals(curatorId)).toBe(true); 169 expect(updatedCollection.createdAt).toEqual(originalCreatedAt); 170 expect(updatedCollection.accessType).toBe(originalAccessType); 171 }); 172 }); 173 174 describe('Authorization', () => { 175 it("should fail when trying to update another user's collection", async () => { 176 const collection = await createCollection(curatorId, 'Original Name'); 177 178 const request = { 179 collectionId: collection.collectionId.getStringValue(), 180 name: 'Unauthorized Update', 181 curatorId: otherCuratorId.value, 182 }; 183 184 const result = await useCase.execute(request); 185 186 expect(result.isErr()).toBe(true); 187 if (result.isErr()) { 188 expect(result.error.message).toContain( 189 'Only the collection author can update the collection', 190 ); 191 } 192 193 // Verify original collection was not modified 194 const originalCollectionResult = await collectionRepository.findById( 195 collection.collectionId, 196 ); 197 const originalCollection = originalCollectionResult.unwrap()!; 198 expect(originalCollection.name.value).toBe('Original Name'); 199 }); 200 201 it('should allow author to update their own collection', async () => { 202 const collection = await createCollection(curatorId, 'Original Name'); 203 204 const request = { 205 collectionId: collection.collectionId.getStringValue(), 206 name: 'Author Update', 207 curatorId: curatorId.value, 208 }; 209 210 const result = await useCase.execute(request); 211 212 expect(result.isOk()).toBe(true); 213 214 // Verify update was successful 215 const updatedCollectionResult = await collectionRepository.findById( 216 collection.collectionId, 217 ); 218 const updatedCollection = updatedCollectionResult.unwrap()!; 219 expect(updatedCollection.name.value).toBe('Author Update'); 220 }); 221 }); 222 223 describe('Validation', () => { 224 it('should fail with invalid collection ID', async () => { 225 const request = { 226 collectionId: 'invalid-collection-id', 227 name: 'Updated Name', 228 curatorId: curatorId.value, 229 }; 230 231 const result = await useCase.execute(request); 232 233 expect(result.isErr()).toBe(true); 234 if (result.isErr()) { 235 expect(result.error.message).toContain('Collection not found'); 236 } 237 }); 238 239 it('should fail with invalid curator ID', async () => { 240 const collection = await createCollection(curatorId, 'Original Name'); 241 242 const request = { 243 collectionId: collection.collectionId.getStringValue(), 244 name: 'Updated Name', 245 curatorId: 'invalid-curator-id', 246 }; 247 248 const result = await useCase.execute(request); 249 250 expect(result.isErr()).toBe(true); 251 if (result.isErr()) { 252 expect(result.error.message).toContain('Invalid curator ID'); 253 } 254 }); 255 256 it('should fail when collection does not exist', async () => { 257 const request = { 258 collectionId: 'non-existent-collection-id', 259 name: 'Updated Name', 260 curatorId: curatorId.value, 261 }; 262 263 const result = await useCase.execute(request); 264 265 expect(result.isErr()).toBe(true); 266 if (result.isErr()) { 267 expect(result.error.message).toContain('Collection not found'); 268 } 269 }); 270 271 it('should fail with empty collection name', async () => { 272 const collection = await createCollection(curatorId, 'Original Name'); 273 274 const request = { 275 collectionId: collection.collectionId.getStringValue(), 276 name: '', 277 curatorId: curatorId.value, 278 }; 279 280 const result = await useCase.execute(request); 281 282 expect(result.isErr()).toBe(true); 283 if (result.isErr()) { 284 expect(result.error.message).toContain( 285 'Collection name cannot be empty', 286 ); 287 } 288 }); 289 290 it('should fail with collection name that is too long', async () => { 291 const collection = await createCollection(curatorId, 'Original Name'); 292 293 const request = { 294 collectionId: collection.collectionId.getStringValue(), 295 name: 'a'.repeat(101), // Exceeds MAX_LENGTH 296 curatorId: curatorId.value, 297 }; 298 299 const result = await useCase.execute(request); 300 301 expect(result.isErr()).toBe(true); 302 if (result.isErr()) { 303 expect(result.error.message).toContain('Collection name cannot exceed'); 304 } 305 }); 306 307 it('should fail with description that is too long', async () => { 308 const collection = await createCollection(curatorId, 'Original Name'); 309 310 const request = { 311 collectionId: collection.collectionId.getStringValue(), 312 name: 'Valid Name', 313 description: 'a'.repeat(501), // Exceeds MAX_LENGTH 314 curatorId: curatorId.value, 315 }; 316 317 const result = await useCase.execute(request); 318 319 expect(result.isErr()).toBe(true); 320 if (result.isErr()) { 321 expect(result.error.message).toContain( 322 'Collection description cannot exceed', 323 ); 324 } 325 }); 326 327 it('should trim whitespace from name and description', async () => { 328 const collection = await createCollection(curatorId, 'Original Name'); 329 330 const request = { 331 collectionId: collection.collectionId.getStringValue(), 332 name: ' Updated Name ', 333 description: ' Updated description ', 334 curatorId: curatorId.value, 335 }; 336 337 const result = await useCase.execute(request); 338 339 expect(result.isOk()).toBe(true); 340 341 // Verify whitespace was trimmed 342 const updatedCollectionResult = await collectionRepository.findById( 343 collection.collectionId, 344 ); 345 const updatedCollection = updatedCollectionResult.unwrap()!; 346 expect(updatedCollection.name.value).toBe('Updated Name'); 347 expect(updatedCollection.description?.value).toBe('Updated description'); 348 }); 349 }); 350 351 describe('Publishing integration', () => { 352 it('should republish collection if it was already published', async () => { 353 const collection = await createCollection(curatorId, 'Original Name'); 354 const initialPublishCount = 355 collectionPublisher.getPublishedCollections().length; 356 357 const request = { 358 collectionId: collection.collectionId.getStringValue(), 359 name: 'Updated Name', 360 curatorId: curatorId.value, 361 }; 362 363 const result = await useCase.execute(request); 364 365 expect(result.isOk()).toBe(true); 366 367 // Verify republishing occurred 368 const finalPublishCount = 369 collectionPublisher.getPublishedCollections().length; 370 expect(finalPublishCount).toBeGreaterThanOrEqual(initialPublishCount); 371 372 // Verify published record ID was updated 373 const updatedCollectionResult = await collectionRepository.findById( 374 collection.collectionId, 375 ); 376 const updatedCollection = updatedCollectionResult.unwrap()!; 377 expect(updatedCollection.isPublished).toBe(true); 378 expect(updatedCollection.publishedRecordId).toBeDefined(); 379 }); 380 381 it('should not republish unpublished collection', async () => { 382 const collection = new CollectionBuilder() 383 .withAuthorId(curatorId.value) 384 .withName('Unpublished Collection') 385 .withPublished(false) 386 .build(); 387 388 if (collection instanceof Error) { 389 throw new Error(`Failed to create collection: ${collection.message}`); 390 } 391 392 await collectionRepository.save(collection); 393 const initialPublishCount = 394 collectionPublisher.getPublishedCollections().length; 395 396 const request = { 397 collectionId: collection.collectionId.getStringValue(), 398 name: 'Updated Unpublished', 399 curatorId: curatorId.value, 400 }; 401 402 const result = await useCase.execute(request); 403 404 expect(result.isOk()).toBe(true); 405 406 // Verify no additional publishing occurred 407 const finalPublishCount = 408 collectionPublisher.getPublishedCollections().length; 409 expect(finalPublishCount).toBe(initialPublishCount); 410 }); 411 412 it('should handle republishing failure gracefully', async () => { 413 const collection = await createCollection(curatorId, 'Original Name'); 414 415 // Configure publisher to fail 416 collectionPublisher.setShouldFail(true); 417 418 const request = { 419 collectionId: collection.collectionId.getStringValue(), 420 name: 'Updated Name', 421 curatorId: curatorId.value, 422 }; 423 424 const result = await useCase.execute(request); 425 426 expect(result.isErr()).toBe(true); 427 if (result.isErr()) { 428 expect(result.error.message).toContain( 429 'Failed to republish collection', 430 ); 431 } 432 }); 433 }); 434 435 describe('Edge cases', () => { 436 it('should handle updating to the same name and description', async () => { 437 const collection = await createCollection( 438 curatorId, 439 'Same Name', 440 'Same description', 441 ); 442 443 const request = { 444 collectionId: collection.collectionId.getStringValue(), 445 name: 'Same Name', 446 description: 'Same description', 447 curatorId: curatorId.value, 448 }; 449 450 const result = await useCase.execute(request); 451 452 expect(result.isOk()).toBe(true); 453 454 // Verify collection was still updated (updatedAt should change) 455 const updatedCollectionResult = await collectionRepository.findById( 456 collection.collectionId, 457 ); 458 const updatedCollection = updatedCollectionResult.unwrap()!; 459 expect(updatedCollection.name.value).toBe('Same Name'); 460 expect(updatedCollection.description?.value).toBe('Same description'); 461 expect(updatedCollection.updatedAt.getTime()).toBeGreaterThanOrEqual( 462 collection.createdAt.getTime(), 463 ); 464 }); 465 466 it('should handle maximum length name and description', async () => { 467 const collection = await createCollection(curatorId, 'Original Name'); 468 469 const request = { 470 collectionId: collection.collectionId.getStringValue(), 471 name: 'a'.repeat(100), // Exactly MAX_LENGTH 472 description: 'b'.repeat(500), // Exactly MAX_LENGTH 473 curatorId: curatorId.value, 474 }; 475 476 const result = await useCase.execute(request); 477 478 expect(result.isOk()).toBe(true); 479 480 // Verify values were saved correctly 481 const updatedCollectionResult = await collectionRepository.findById( 482 collection.collectionId, 483 ); 484 const updatedCollection = updatedCollectionResult.unwrap()!; 485 expect(updatedCollection.name.value.length).toBe(100); 486 expect(updatedCollection.description?.value.length).toBe(500); 487 }); 488 }); 489});