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});