A social knowledge tool for researchers built on ATProto
1import { UpdateNoteCardUseCase } from '../../application/useCases/commands/UpdateNoteCardUseCase'; 2import { InMemoryCardRepository } from '../utils/InMemoryCardRepository'; 3import { FakeCardPublisher } from '../utils/FakeCardPublisher'; 4import { CuratorId } from '../../domain/value-objects/CuratorId'; 5import { 6 CardFactory, 7 INoteCardInput, 8 IUrlCardInput, 9} from '../../domain/CardFactory'; 10import { CardTypeEnum } from '../../domain/value-objects/CardType'; 11import { CardLibraryService } from '../../domain/services/CardLibraryService'; 12import { UrlMetadata } from '../../domain/value-objects/UrlMetadata'; 13import { InMemoryCollectionRepository } from '../utils/InMemoryCollectionRepository'; 14import { CardCollectionService } from '../../domain/services/CardCollectionService'; 15import { FakeCollectionPublisher } from '../utils/FakeCollectionPublisher'; 16 17describe('UpdateNoteCardUseCase', () => { 18 let useCase: UpdateNoteCardUseCase; 19 let cardRepository: InMemoryCardRepository; 20 let collectionRepository: InMemoryCollectionRepository; 21 let cardPublisher: FakeCardPublisher; 22 let collectionPublisher: FakeCollectionPublisher; 23 let cardCollectionService: CardCollectionService; 24 let cardLibraryService: CardLibraryService; 25 let curatorId: CuratorId; 26 let otherCuratorId: CuratorId; 27 28 beforeEach(() => { 29 cardRepository = new InMemoryCardRepository(); 30 cardPublisher = new FakeCardPublisher(); 31 collectionRepository = new InMemoryCollectionRepository(); 32 collectionPublisher = new FakeCollectionPublisher(); 33 cardCollectionService = new CardCollectionService( 34 collectionRepository, 35 collectionPublisher, 36 ); 37 38 cardLibraryService = new CardLibraryService( 39 cardRepository, 40 cardPublisher, 41 collectionRepository, 42 cardCollectionService, 43 ); 44 45 useCase = new UpdateNoteCardUseCase(cardRepository, cardPublisher); 46 47 curatorId = CuratorId.create('did:plc:testcurator').unwrap(); 48 otherCuratorId = CuratorId.create('did:plc:othercurator').unwrap(); 49 }); 50 51 afterEach(() => { 52 cardRepository.clear(); 53 cardPublisher.clear(); 54 }); 55 56 const createNoteCard = async (authorId: CuratorId, text: string) => { 57 const noteCardInput: INoteCardInput = { 58 type: CardTypeEnum.NOTE, 59 text, 60 }; 61 62 const noteCardResult = CardFactory.create({ 63 curatorId: authorId.value, 64 cardInput: noteCardInput, 65 }); 66 67 if (noteCardResult.isErr()) { 68 throw new Error( 69 `Failed to create note card: ${noteCardResult.error.message}`, 70 ); 71 } 72 73 const noteCard = noteCardResult.value; 74 75 // Add to library and save 76 await cardLibraryService.addCardToLibrary(noteCard, authorId); 77 await cardRepository.save(noteCard); 78 79 return noteCard; 80 }; 81 82 describe('Basic note card update', () => { 83 it('should successfully update a note card', async () => { 84 const originalText = 'This is my original note'; 85 const updatedText = 'This is my updated note with more content'; 86 87 // Create a note card 88 const noteCard = await createNoteCard(curatorId, originalText); 89 90 const request = { 91 cardId: noteCard.cardId.getStringValue(), 92 note: updatedText, 93 curatorId: curatorId.value, 94 }; 95 96 const result = await useCase.execute(request); 97 98 expect(result.isOk()).toBe(true); 99 const response = result.unwrap(); 100 expect(response.cardId).toBe(noteCard.cardId.getStringValue()); 101 102 // Verify card was updated in repository 103 const updatedCardResult = await cardRepository.findById(noteCard.cardId); 104 expect(updatedCardResult.isOk()).toBe(true); 105 const updatedCard = updatedCardResult.unwrap(); 106 expect(updatedCard).toBeDefined(); 107 expect(updatedCard!.content.noteContent!.text).toBe(updatedText); 108 109 // Verify library membership is marked as published 110 const libraryInfo = updatedCard!.getLibraryInfo(curatorId); 111 expect(libraryInfo).toBeDefined(); 112 expect(libraryInfo!.publishedRecordId).toBeDefined(); 113 }); 114 115 it('should update card content while preserving other properties', async () => { 116 const originalText = 'Original note'; 117 const updatedText = 'Updated note'; 118 119 // Create a note card 120 const noteCard = await createNoteCard(curatorId, originalText); 121 const originalCreatedAt = noteCard.createdAt; 122 const originalCardId = noteCard.cardId.getStringValue(); 123 124 const request = { 125 cardId: originalCardId, 126 note: updatedText, 127 curatorId: curatorId.value, 128 }; 129 130 const result = await useCase.execute(request); 131 132 expect(result.isOk()).toBe(true); 133 134 // Verify card properties are preserved 135 const updatedCardResult = await cardRepository.findById(noteCard.cardId); 136 const updatedCard = updatedCardResult.unwrap()!; 137 138 expect(updatedCard.cardId.getStringValue()).toBe(originalCardId); 139 expect(updatedCard.createdAt).toEqual(originalCreatedAt); 140 expect(updatedCard.updatedAt.getTime()).toBeGreaterThanOrEqual( 141 originalCreatedAt.getTime(), 142 ); 143 expect(updatedCard.type.value).toBe(CardTypeEnum.NOTE); 144 expect(updatedCard.curatorId.equals(curatorId)).toBe(true); 145 }); 146 }); 147 148 describe('Authorization', () => { 149 it("should fail when trying to update another user's note card", async () => { 150 const originalText = "This is someone else's note"; 151 152 // Create a note card with one curator 153 const noteCard = await createNoteCard(curatorId, originalText); 154 155 // Try to update with different curator 156 const request = { 157 cardId: noteCard.cardId.getStringValue(), 158 note: "Trying to update someone else's note", 159 curatorId: otherCuratorId.value, 160 }; 161 162 const result = await useCase.execute(request); 163 164 expect(result.isErr()).toBe(true); 165 if (result.isErr()) { 166 expect(result.error.message).toContain( 167 'Only the author can update this note card', 168 ); 169 } 170 171 // Verify original card was not modified 172 const originalCardResult = await cardRepository.findById(noteCard.cardId); 173 const originalCard = originalCardResult.unwrap()!; 174 expect(originalCard.content.noteContent!.text).toBe(originalText); 175 }); 176 177 it('should allow author to update their own note card', async () => { 178 const originalText = 'My note'; 179 const updatedText = 'My updated note'; 180 181 // Create and update with same curator 182 const noteCard = await createNoteCard(curatorId, originalText); 183 184 const request = { 185 cardId: noteCard.cardId.getStringValue(), 186 note: updatedText, 187 curatorId: curatorId.value, 188 }; 189 190 const result = await useCase.execute(request); 191 192 expect(result.isOk()).toBe(true); 193 194 // Verify update was successful 195 const updatedCardResult = await cardRepository.findById(noteCard.cardId); 196 const updatedCard = updatedCardResult.unwrap()!; 197 expect(updatedCard.content.noteContent!.text).toBe(updatedText); 198 }); 199 }); 200 201 describe('Validation', () => { 202 it('should fail with invalid curator ID', async () => { 203 const noteCard = await createNoteCard(curatorId, 'Some note'); 204 205 const request = { 206 cardId: noteCard.cardId.getStringValue(), 207 note: 'Updated note', 208 curatorId: 'invalid-curator-id', 209 }; 210 211 const result = await useCase.execute(request); 212 213 expect(result.isErr()).toBe(true); 214 if (result.isErr()) { 215 expect(result.error.message).toContain('Invalid curator ID'); 216 } 217 }); 218 219 it('should fail when card does not exist', async () => { 220 const request = { 221 cardId: 'non-existent-card-id', 222 note: 'Some note text', 223 curatorId: curatorId.value, 224 }; 225 226 const result = await useCase.execute(request); 227 228 expect(result.isErr()).toBe(true); 229 if (result.isErr()) { 230 expect(result.error.message).toContain('Card not found'); 231 } 232 }); 233 234 it('should fail when trying to update a non-note card', async () => { 235 // Create a URL card instead of a note card 236 const urlCardInput: IUrlCardInput = { 237 type: CardTypeEnum.URL, 238 url: 'https://example.com', 239 metadata: UrlMetadata.create({ 240 title: 'Example URL', 241 description: 'This is an example URL card', 242 imageUrl: 'https://example.com/image.png', 243 type: 'article', 244 url: 'https://example.com', 245 author: 'John Doe', 246 publishedDate: new Date('2023-01-01'), 247 siteName: 'Example Site', 248 retrievedAt: new Date(), 249 }).unwrap(), 250 }; 251 252 const urlCardResult = CardFactory.create({ 253 curatorId: curatorId.value, 254 cardInput: urlCardInput, 255 }); 256 257 if (urlCardResult.isErr()) { 258 throw new Error( 259 `Failed to create URL card: ${urlCardResult.error.message}`, 260 ); 261 } 262 263 const urlCard = urlCardResult.value; 264 await cardRepository.save(urlCard); 265 266 const request = { 267 cardId: urlCard.cardId.getStringValue(), 268 note: 'Trying to update a URL card as note', 269 curatorId: curatorId.value, 270 }; 271 272 const result = await useCase.execute(request); 273 274 expect(result.isErr()).toBe(true); 275 if (result.isErr()) { 276 expect(result.error.message).toContain( 277 'Card is not a note card and cannot be updated', 278 ); 279 } 280 }); 281 282 it('should fail with empty note text', async () => { 283 const noteCard = await createNoteCard(curatorId, 'Original note'); 284 285 const request = { 286 cardId: noteCard.cardId.getStringValue(), 287 note: '', 288 curatorId: curatorId.value, 289 }; 290 291 const result = await useCase.execute(request); 292 293 expect(result.isErr()).toBe(true); 294 if (result.isErr()) { 295 expect(result.error.message).toContain('Note text cannot be empty'); 296 } 297 }); 298 299 it('should fail with note text that is too long', async () => { 300 const noteCard = await createNoteCard(curatorId, 'Original note'); 301 const tooLongText = 'a'.repeat(10001); // Exceeds MAX_TEXT_LENGTH 302 303 const request = { 304 cardId: noteCard.cardId.getStringValue(), 305 note: tooLongText, 306 curatorId: curatorId.value, 307 }; 308 309 const result = await useCase.execute(request); 310 311 expect(result.isErr()).toBe(true); 312 if (result.isErr()) { 313 expect(result.error.message).toContain('Note text cannot exceed'); 314 } 315 }); 316 317 it('should trim whitespace from note text', async () => { 318 const noteCard = await createNoteCard(curatorId, 'Original note'); 319 const textWithWhitespace = ' Updated note with whitespace '; 320 const expectedTrimmedText = 'Updated note with whitespace'; 321 322 const request = { 323 cardId: noteCard.cardId.getStringValue(), 324 note: textWithWhitespace, 325 curatorId: curatorId.value, 326 }; 327 328 const result = await useCase.execute(request); 329 330 expect(result.isOk()).toBe(true); 331 332 // Verify text was trimmed 333 const updatedCardResult = await cardRepository.findById(noteCard.cardId); 334 const updatedCard = updatedCardResult.unwrap()!; 335 expect(updatedCard.content.noteContent!.text).toBe(expectedTrimmedText); 336 }); 337 }); 338 339 describe('Publishing integration', () => { 340 it('should publish updated card before saving to repository', async () => { 341 const noteCard = await createNoteCard(curatorId, 'Original note'); 342 343 const request = { 344 cardId: noteCard.cardId.getStringValue(), 345 note: 'Updated note', 346 curatorId: curatorId.value, 347 }; 348 349 const result = await useCase.execute(request); 350 351 expect(result.isOk()).toBe(true); 352 353 // Verify the published record ID was updated in the library membership 354 const updatedCardResult = await cardRepository.findById(noteCard.cardId); 355 const updatedCard = updatedCardResult.unwrap()!; 356 const libraryInfo = updatedCard.getLibraryInfo(curatorId); 357 expect(libraryInfo!.publishedRecordId).toBeDefined(); 358 }); 359 }); 360 361 describe('Edge cases', () => { 362 it('should handle updating to the same text', async () => { 363 const noteText = 'Same note text'; 364 const noteCard = await createNoteCard(curatorId, noteText); 365 366 const request = { 367 cardId: noteCard.cardId.getStringValue(), 368 note: noteText, 369 curatorId: curatorId.value, 370 }; 371 372 const result = await useCase.execute(request); 373 374 expect(result.isOk()).toBe(true); 375 376 // Verify card was still updated (updatedAt should change) 377 const updatedCardResult = await cardRepository.findById(noteCard.cardId); 378 const updatedCard = updatedCardResult.unwrap()!; 379 expect(updatedCard.content.noteContent!.text).toBe(noteText); 380 expect(updatedCard.updatedAt.getTime()).toBeGreaterThanOrEqual( 381 noteCard.createdAt.getTime(), 382 ); 383 }); 384 385 it('should handle maximum length note text', async () => { 386 const noteCard = await createNoteCard(curatorId, 'Original note'); 387 const maxLengthText = 'a'.repeat(10000); // Exactly MAX_TEXT_LENGTH 388 389 const request = { 390 cardId: noteCard.cardId.getStringValue(), 391 note: maxLengthText, 392 curatorId: curatorId.value, 393 }; 394 395 const result = await useCase.execute(request); 396 397 expect(result.isOk()).toBe(true); 398 399 // Verify text was saved correctly 400 const updatedCardResult = await cardRepository.findById(noteCard.cardId); 401 const updatedCard = updatedCardResult.unwrap()!; 402 expect(updatedCard.content.noteContent!.text).toBe(maxLengthText); 403 expect(updatedCard.content.noteContent!.text.length).toBe(10000); 404 }); 405 }); 406});