A social knowledge tool for researchers built on ATProto
1import { DeleteCollectionUseCase } from '../../application/useCases/commands/DeleteCollectionUseCase'; 2import { InMemoryCollectionRepository } from '../utils/InMemoryCollectionRepository'; 3import { FakeCollectionPublisher } from '../utils/FakeCollectionPublisher'; 4import { CuratorId } from '../../domain/value-objects/CuratorId'; 5import { CollectionBuilder } from '../utils/builders/CollectionBuilder'; 6 7describe('DeleteCollectionUseCase', () => { 8 let useCase: DeleteCollectionUseCase; 9 let collectionRepository: InMemoryCollectionRepository; 10 let collectionPublisher: FakeCollectionPublisher; 11 let curatorId: CuratorId; 12 let otherCuratorId: CuratorId; 13 14 beforeEach(() => { 15 collectionRepository = new InMemoryCollectionRepository(); 16 collectionPublisher = new FakeCollectionPublisher(); 17 useCase = new DeleteCollectionUseCase( 18 collectionRepository, 19 collectionPublisher, 20 ); 21 22 curatorId = CuratorId.create('did:plc:testcurator').unwrap(); 23 otherCuratorId = CuratorId.create('did:plc:othercurator').unwrap(); 24 }); 25 26 afterEach(() => { 27 collectionRepository.clear(); 28 collectionPublisher.clear(); 29 }); 30 31 const createCollection = async ( 32 authorId: CuratorId, 33 name: string, 34 published: boolean = true, 35 ) => { 36 const collection = new CollectionBuilder() 37 .withAuthorId(authorId.value) 38 .withName(name) 39 .withPublished(published) 40 .build(); 41 42 if (collection instanceof Error) { 43 throw new Error(`Failed to create collection: ${collection.message}`); 44 } 45 46 await collectionRepository.save(collection); 47 return collection; 48 }; 49 50 describe('Basic collection deletion', () => { 51 it('should successfully delete a published collection', async () => { 52 const collection = await createCollection( 53 curatorId, 54 'Collection to Delete', 55 true, 56 ); 57 58 const request = { 59 collectionId: collection.collectionId.getStringValue(), 60 curatorId: curatorId.value, 61 }; 62 63 const result = await useCase.execute(request); 64 65 expect(result.isOk()).toBe(true); 66 const response = result.unwrap(); 67 expect(response.collectionId).toBe( 68 collection.collectionId.getStringValue(), 69 ); 70 71 // Verify collection was deleted from repository 72 const deletedCollectionResult = await collectionRepository.findById( 73 collection.collectionId, 74 ); 75 expect(deletedCollectionResult.unwrap()).toBeNull(); 76 77 // Verify collection was unpublished 78 const unpublishedCollections = 79 collectionPublisher.getUnpublishedCollections(); 80 expect(unpublishedCollections).toHaveLength(1); 81 expect(unpublishedCollections[0]?.uri).toBe( 82 collection.publishedRecordId?.uri, 83 ); 84 }); 85 86 it('should successfully delete an unpublished collection', async () => { 87 const collection = await createCollection( 88 curatorId, 89 'Unpublished Collection', 90 false, 91 ); 92 93 const request = { 94 collectionId: collection.collectionId.getStringValue(), 95 curatorId: curatorId.value, 96 }; 97 98 const result = await useCase.execute(request); 99 100 expect(result.isOk()).toBe(true); 101 102 // Verify collection was deleted from repository 103 const deletedCollectionResult = await collectionRepository.findById( 104 collection.collectionId, 105 ); 106 expect(deletedCollectionResult.unwrap()).toBeNull(); 107 108 // Verify no unpublish operation was performed 109 const unpublishedCollections = 110 collectionPublisher.getUnpublishedCollections(); 111 expect(unpublishedCollections).toHaveLength(0); 112 }); 113 114 it('should delete collection without affecting other collections', async () => { 115 const collection1 = await createCollection(curatorId, 'Collection 1'); 116 const collection2 = await createCollection(curatorId, 'Collection 2'); 117 118 const request = { 119 collectionId: collection1.collectionId.getStringValue(), 120 curatorId: curatorId.value, 121 }; 122 123 const result = await useCase.execute(request); 124 125 expect(result.isOk()).toBe(true); 126 127 // Verify only the specified collection was deleted 128 const remainingCollections = collectionRepository.getAllCollections(); 129 expect(remainingCollections).toHaveLength(1); 130 expect(remainingCollections[0]!.collectionId.getStringValue()).toBe( 131 collection2.collectionId.getStringValue(), 132 ); 133 }); 134 }); 135 136 describe('Authorization', () => { 137 it("should fail when trying to delete another user's collection", async () => { 138 const collection = await createCollection( 139 curatorId, 140 "Someone Else's Collection", 141 ); 142 143 const request = { 144 collectionId: collection.collectionId.getStringValue(), 145 curatorId: otherCuratorId.value, 146 }; 147 148 const result = await useCase.execute(request); 149 150 expect(result.isErr()).toBe(true); 151 if (result.isErr()) { 152 expect(result.error.message).toContain( 153 'Only the collection author can delete the collection', 154 ); 155 } 156 157 // Verify collection was not deleted 158 const existingCollectionResult = await collectionRepository.findById( 159 collection.collectionId, 160 ); 161 expect(existingCollectionResult.unwrap()).not.toBeNull(); 162 }); 163 164 it('should allow author to delete their own collection', async () => { 165 const collection = await createCollection(curatorId, 'My Collection'); 166 167 const request = { 168 collectionId: collection.collectionId.getStringValue(), 169 curatorId: curatorId.value, 170 }; 171 172 const result = await useCase.execute(request); 173 174 expect(result.isOk()).toBe(true); 175 176 // Verify collection was deleted 177 const deletedCollectionResult = await collectionRepository.findById( 178 collection.collectionId, 179 ); 180 expect(deletedCollectionResult.unwrap()).toBeNull(); 181 }); 182 }); 183 184 describe('Validation', () => { 185 it('should fail with invalid collection ID', async () => { 186 const request = { 187 collectionId: 'invalid-collection-id', 188 curatorId: curatorId.value, 189 }; 190 191 const result = await useCase.execute(request); 192 193 expect(result.isErr()).toBe(true); 194 if (result.isErr()) { 195 expect(result.error.message).toContain('Collection not found'); 196 } 197 }); 198 199 it('should fail with invalid curator ID', async () => { 200 const collection = await createCollection(curatorId, 'Test Collection'); 201 202 const request = { 203 collectionId: collection.collectionId.getStringValue(), 204 curatorId: 'invalid-curator-id', 205 }; 206 207 const result = await useCase.execute(request); 208 209 expect(result.isErr()).toBe(true); 210 if (result.isErr()) { 211 expect(result.error.message).toContain('Invalid curator ID'); 212 } 213 }); 214 215 it('should fail when collection does not exist', async () => { 216 const request = { 217 collectionId: 'non-existent-collection-id', 218 curatorId: curatorId.value, 219 }; 220 221 const result = await useCase.execute(request); 222 223 expect(result.isErr()).toBe(true); 224 if (result.isErr()) { 225 expect(result.error.message).toContain('Collection not found'); 226 } 227 }); 228 }); 229 230 describe('Publishing integration', () => { 231 it('should unpublish collection before deletion', async () => { 232 const collection = await createCollection( 233 curatorId, 234 'Published Collection', 235 true, 236 ); 237 const initialUnpublishCount = 238 collectionPublisher.getUnpublishedCollections().length; 239 240 const request = { 241 collectionId: collection.collectionId.getStringValue(), 242 curatorId: curatorId.value, 243 }; 244 245 const result = await useCase.execute(request); 246 247 expect(result.isOk()).toBe(true); 248 249 // Verify unpublish operation occurred 250 const finalUnpublishCount = 251 collectionPublisher.getUnpublishedCollections().length; 252 expect(finalUnpublishCount).toBe(initialUnpublishCount + 1); 253 254 // Verify the correct collection was unpublished 255 const unpublishedCollections = 256 collectionPublisher.getUnpublishedCollections(); 257 const unpublishedCollection = unpublishedCollections.find( 258 (uc) => uc.uri === collection.publishedRecordId?.uri, 259 ); 260 expect(unpublishedCollection).toBeDefined(); 261 }); 262 263 it('should handle unpublish failure gracefully', async () => { 264 const collection = await createCollection( 265 curatorId, 266 'Collection with Unpublish Failure', 267 true, 268 ); 269 270 // Configure publisher to fail unpublish 271 collectionPublisher.setShouldFailUnpublish(true); 272 273 const request = { 274 collectionId: collection.collectionId.getStringValue(), 275 curatorId: curatorId.value, 276 }; 277 278 const result = await useCase.execute(request); 279 280 expect(result.isErr()).toBe(true); 281 if (result.isErr()) { 282 expect(result.error.message).toContain( 283 'Failed to unpublish collection', 284 ); 285 } 286 287 // Verify collection was not deleted if unpublish failed 288 const existingCollectionResult = await collectionRepository.findById( 289 collection.collectionId, 290 ); 291 expect(existingCollectionResult.unwrap()).not.toBeNull(); 292 }); 293 294 it('should not attempt to unpublish if collection was never published', async () => { 295 const collection = await createCollection( 296 curatorId, 297 'Never Published Collection', 298 false, 299 ); 300 const initialUnpublishCount = 301 collectionPublisher.getUnpublishedCollections().length; 302 303 const request = { 304 collectionId: collection.collectionId.getStringValue(), 305 curatorId: curatorId.value, 306 }; 307 308 const result = await useCase.execute(request); 309 310 expect(result.isOk()).toBe(true); 311 312 // Verify no unpublish operation occurred 313 const finalUnpublishCount = 314 collectionPublisher.getUnpublishedCollections().length; 315 expect(finalUnpublishCount).toBe(initialUnpublishCount); 316 317 // Verify collection was still deleted 318 const deletedCollectionResult = await collectionRepository.findById( 319 collection.collectionId, 320 ); 321 expect(deletedCollectionResult.unwrap()).toBeNull(); 322 }); 323 }); 324 325 describe('Edge cases', () => { 326 it('should handle deletion of collection with no published record ID', async () => { 327 const collection = new CollectionBuilder() 328 .withAuthorId(curatorId.value) 329 .withName('Collection Without Published Record') 330 .withPublished(true) 331 .build(); 332 333 if (collection instanceof Error) { 334 throw new Error(`Failed to create collection: ${collection.message}`); 335 } 336 337 // Manually clear the published record ID to simulate edge case 338 (collection as any).props.publishedRecordId = undefined; 339 await collectionRepository.save(collection); 340 341 const request = { 342 collectionId: collection.collectionId.getStringValue(), 343 curatorId: curatorId.value, 344 }; 345 346 const result = await useCase.execute(request); 347 348 expect(result.isOk()).toBe(true); 349 350 // Verify collection was deleted despite missing published record ID 351 const deletedCollectionResult = await collectionRepository.findById( 352 collection.collectionId, 353 ); 354 expect(deletedCollectionResult.unwrap()).toBeNull(); 355 }); 356 357 it('should handle multiple deletion attempts on same collection', async () => { 358 const collection = await createCollection( 359 curatorId, 360 'Collection to Delete Twice', 361 ); 362 363 const request = { 364 collectionId: collection.collectionId.getStringValue(), 365 curatorId: curatorId.value, 366 }; 367 368 // First deletion should succeed 369 const firstResult = await useCase.execute(request); 370 expect(firstResult.isOk()).toBe(true); 371 372 // Second deletion should fail 373 const secondResult = await useCase.execute(request); 374 expect(secondResult.isErr()).toBe(true); 375 if (secondResult.isErr()) { 376 expect(secondResult.error.message).toContain('Collection not found'); 377 } 378 }); 379 }); 380});