@jant/core 0.3.2 → 0.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/routes/api/posts.js +1 -1
- package/package.json +10 -4
- package/src/__tests__/helpers/app.ts +97 -0
- package/src/__tests__/helpers/db.ts +85 -0
- package/src/lib/__tests__/constants.test.ts +44 -0
- package/src/lib/__tests__/markdown.test.ts +133 -0
- package/src/lib/__tests__/schemas.test.ts +220 -0
- package/src/lib/__tests__/sqid.test.ts +65 -0
- package/src/lib/__tests__/sse.test.ts +86 -0
- package/src/lib/__tests__/time.test.ts +112 -0
- package/src/lib/__tests__/url.test.ts +138 -0
- package/src/middleware/__tests__/auth.test.ts +139 -0
- package/src/routes/api/__tests__/posts.test.ts +306 -0
- package/src/routes/api/__tests__/search.test.ts +77 -0
- package/src/routes/api/posts.ts +3 -1
- package/src/services/__tests__/collection.test.ts +226 -0
- package/src/services/__tests__/media.test.ts +134 -0
- package/src/services/__tests__/post.test.ts +636 -0
- package/src/services/__tests__/redirect.test.ts +110 -0
- package/src/services/__tests__/search.test.ts +143 -0
- package/src/services/__tests__/settings.test.ts +110 -0
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
import { createTestDatabase } from "../../__tests__/helpers/db.js";
|
|
3
|
+
import { createPostService } from "../post.js";
|
|
4
|
+
import type { Database } from "../../db/index.js";
|
|
5
|
+
|
|
6
|
+
describe("PostService", () => {
|
|
7
|
+
let db: Database;
|
|
8
|
+
let postService: ReturnType<typeof createPostService>;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
const testDb = createTestDatabase();
|
|
12
|
+
db = testDb.db as unknown as Database;
|
|
13
|
+
postService = createPostService(db);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("create", () => {
|
|
17
|
+
it("creates a note post with required fields", async () => {
|
|
18
|
+
const post = await postService.create({
|
|
19
|
+
type: "note",
|
|
20
|
+
content: "Hello world",
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
expect(post.id).toBe(1);
|
|
24
|
+
expect(post.type).toBe("note");
|
|
25
|
+
expect(post.content).toBe("Hello world");
|
|
26
|
+
expect(post.visibility).toBe("quiet"); // default
|
|
27
|
+
expect(post.contentHtml).toContain("<p>Hello world</p>");
|
|
28
|
+
expect(post.deletedAt).toBeNull();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("creates a post with all fields", async () => {
|
|
32
|
+
const post = await postService.create({
|
|
33
|
+
type: "article",
|
|
34
|
+
title: "My Article",
|
|
35
|
+
content: "# Introduction\n\nSome content.",
|
|
36
|
+
visibility: "featured",
|
|
37
|
+
path: "my-article",
|
|
38
|
+
sourceUrl: "https://example.com/source",
|
|
39
|
+
sourceName: "Example",
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
expect(post.type).toBe("article");
|
|
43
|
+
expect(post.title).toBe("My Article");
|
|
44
|
+
expect(post.visibility).toBe("featured");
|
|
45
|
+
expect(post.path).toBe("my-article");
|
|
46
|
+
expect(post.sourceUrl).toBe("https://example.com/source");
|
|
47
|
+
expect(post.sourceName).toBe("Example");
|
|
48
|
+
expect(post.sourceDomain).toBe("example.com");
|
|
49
|
+
expect(post.contentHtml).toContain("<h1>");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("renders markdown content to HTML", async () => {
|
|
53
|
+
const post = await postService.create({
|
|
54
|
+
type: "note",
|
|
55
|
+
content: "This is **bold** text",
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
expect(post.contentHtml).toContain("<strong>bold</strong>");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("extracts domain from source URL", async () => {
|
|
62
|
+
const post = await postService.create({
|
|
63
|
+
type: "link",
|
|
64
|
+
content: "Check this out",
|
|
65
|
+
sourceUrl: "https://blog.example.org/article",
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
expect(post.sourceDomain).toBe("blog.example.org");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("sets publishedAt and timestamps", async () => {
|
|
72
|
+
const post = await postService.create({
|
|
73
|
+
type: "note",
|
|
74
|
+
content: "test",
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
expect(post.publishedAt).toBeGreaterThan(0);
|
|
78
|
+
expect(post.createdAt).toBeGreaterThan(0);
|
|
79
|
+
expect(post.updatedAt).toBeGreaterThan(0);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("allows custom publishedAt", async () => {
|
|
83
|
+
const customTime = 1706745600;
|
|
84
|
+
const post = await postService.create({
|
|
85
|
+
type: "note",
|
|
86
|
+
content: "test",
|
|
87
|
+
publishedAt: customTime,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
expect(post.publishedAt).toBe(customTime);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("creates incrementing IDs", async () => {
|
|
94
|
+
const post1 = await postService.create({
|
|
95
|
+
type: "note",
|
|
96
|
+
content: "first",
|
|
97
|
+
});
|
|
98
|
+
const post2 = await postService.create({
|
|
99
|
+
type: "note",
|
|
100
|
+
content: "second",
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
expect(post2.id).toBeGreaterThan(post1.id);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("getById", () => {
|
|
108
|
+
it("returns a post by ID", async () => {
|
|
109
|
+
const created = await postService.create({
|
|
110
|
+
type: "note",
|
|
111
|
+
content: "test",
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const found = await postService.getById(created.id);
|
|
115
|
+
expect(found).not.toBeNull();
|
|
116
|
+
expect(found?.id).toBe(created.id);
|
|
117
|
+
expect(found?.content).toBe("test");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("returns null for non-existent ID", async () => {
|
|
121
|
+
const found = await postService.getById(9999);
|
|
122
|
+
expect(found).toBeNull();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("excludes soft-deleted posts", async () => {
|
|
126
|
+
const post = await postService.create({
|
|
127
|
+
type: "note",
|
|
128
|
+
content: "test",
|
|
129
|
+
});
|
|
130
|
+
await postService.delete(post.id);
|
|
131
|
+
|
|
132
|
+
const found = await postService.getById(post.id);
|
|
133
|
+
expect(found).toBeNull();
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("getByPath", () => {
|
|
138
|
+
it("returns a post by path", async () => {
|
|
139
|
+
await postService.create({
|
|
140
|
+
type: "page",
|
|
141
|
+
content: "About page",
|
|
142
|
+
path: "about",
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const found = await postService.getByPath("about");
|
|
146
|
+
expect(found).not.toBeNull();
|
|
147
|
+
expect(found?.path).toBe("about");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("returns null for non-existent path", async () => {
|
|
151
|
+
const found = await postService.getByPath("nonexistent");
|
|
152
|
+
expect(found).toBeNull();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("excludes soft-deleted posts", async () => {
|
|
156
|
+
const post = await postService.create({
|
|
157
|
+
type: "page",
|
|
158
|
+
content: "test",
|
|
159
|
+
path: "test-page",
|
|
160
|
+
});
|
|
161
|
+
await postService.delete(post.id);
|
|
162
|
+
|
|
163
|
+
const found = await postService.getByPath("test-page");
|
|
164
|
+
expect(found).toBeNull();
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe("list", () => {
|
|
169
|
+
it("returns empty array when no posts exist", async () => {
|
|
170
|
+
const posts = await postService.list();
|
|
171
|
+
expect(posts).toEqual([]);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("returns all non-deleted posts", async () => {
|
|
175
|
+
await postService.create({ type: "note", content: "first" });
|
|
176
|
+
await postService.create({ type: "note", content: "second" });
|
|
177
|
+
await postService.create({ type: "note", content: "third" });
|
|
178
|
+
|
|
179
|
+
const posts = await postService.list();
|
|
180
|
+
expect(posts).toHaveLength(3);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("orders by publishedAt descending", async () => {
|
|
184
|
+
await postService.create({
|
|
185
|
+
type: "note",
|
|
186
|
+
content: "old",
|
|
187
|
+
publishedAt: 1000,
|
|
188
|
+
});
|
|
189
|
+
await postService.create({
|
|
190
|
+
type: "note",
|
|
191
|
+
content: "new",
|
|
192
|
+
publishedAt: 2000,
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const posts = await postService.list();
|
|
196
|
+
expect(posts[0]?.content).toBe("new");
|
|
197
|
+
expect(posts[1]?.content).toBe("old");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("filters by type", async () => {
|
|
201
|
+
await postService.create({ type: "note", content: "a note" });
|
|
202
|
+
await postService.create({
|
|
203
|
+
type: "article",
|
|
204
|
+
content: "an article",
|
|
205
|
+
title: "Article",
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const notes = await postService.list({ type: "note" });
|
|
209
|
+
expect(notes).toHaveLength(1);
|
|
210
|
+
expect(notes[0]?.type).toBe("note");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("filters by single visibility", async () => {
|
|
214
|
+
await postService.create({
|
|
215
|
+
type: "note",
|
|
216
|
+
content: "featured",
|
|
217
|
+
visibility: "featured",
|
|
218
|
+
});
|
|
219
|
+
await postService.create({
|
|
220
|
+
type: "note",
|
|
221
|
+
content: "draft",
|
|
222
|
+
visibility: "draft",
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const featured = await postService.list({ visibility: "featured" });
|
|
226
|
+
expect(featured).toHaveLength(1);
|
|
227
|
+
expect(featured[0]?.visibility).toBe("featured");
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("filters by multiple visibility levels", async () => {
|
|
231
|
+
await postService.create({
|
|
232
|
+
type: "note",
|
|
233
|
+
content: "featured",
|
|
234
|
+
visibility: "featured",
|
|
235
|
+
});
|
|
236
|
+
await postService.create({
|
|
237
|
+
type: "note",
|
|
238
|
+
content: "quiet",
|
|
239
|
+
visibility: "quiet",
|
|
240
|
+
});
|
|
241
|
+
await postService.create({
|
|
242
|
+
type: "note",
|
|
243
|
+
content: "draft",
|
|
244
|
+
visibility: "draft",
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
const publicPosts = await postService.list({
|
|
248
|
+
visibility: ["featured", "quiet"],
|
|
249
|
+
});
|
|
250
|
+
expect(publicPosts).toHaveLength(2);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("excludes deleted posts by default", async () => {
|
|
254
|
+
const post = await postService.create({
|
|
255
|
+
type: "note",
|
|
256
|
+
content: "test",
|
|
257
|
+
});
|
|
258
|
+
await postService.create({ type: "note", content: "kept" });
|
|
259
|
+
await postService.delete(post.id);
|
|
260
|
+
|
|
261
|
+
const posts = await postService.list();
|
|
262
|
+
expect(posts).toHaveLength(1);
|
|
263
|
+
expect(posts[0]?.content).toBe("kept");
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("includes deleted posts when requested", async () => {
|
|
267
|
+
const post = await postService.create({
|
|
268
|
+
type: "note",
|
|
269
|
+
content: "test",
|
|
270
|
+
});
|
|
271
|
+
await postService.delete(post.id);
|
|
272
|
+
|
|
273
|
+
const posts = await postService.list({ includeDeleted: true });
|
|
274
|
+
expect(posts).toHaveLength(1);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("supports limit", async () => {
|
|
278
|
+
for (let i = 0; i < 5; i++) {
|
|
279
|
+
await postService.create({ type: "note", content: `post ${i}` });
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const posts = await postService.list({ limit: 2 });
|
|
283
|
+
expect(posts).toHaveLength(2);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("supports cursor pagination", async () => {
|
|
287
|
+
const created = [];
|
|
288
|
+
for (let i = 0; i < 5; i++) {
|
|
289
|
+
created.push(
|
|
290
|
+
await postService.create({
|
|
291
|
+
type: "note",
|
|
292
|
+
content: `post ${i}`,
|
|
293
|
+
publishedAt: 1000 + i,
|
|
294
|
+
}),
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Get posts with ID less than the 3rd post
|
|
299
|
+
const thirdPostId = created[2]?.id ?? 0;
|
|
300
|
+
const posts = await postService.list({ cursor: thirdPostId });
|
|
301
|
+
expect(posts.every((p) => p.id < thirdPostId)).toBe(true);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("excludes replies when requested", async () => {
|
|
305
|
+
const root = await postService.create({
|
|
306
|
+
type: "note",
|
|
307
|
+
content: "root post",
|
|
308
|
+
});
|
|
309
|
+
await postService.create({
|
|
310
|
+
type: "note",
|
|
311
|
+
content: "reply",
|
|
312
|
+
replyToId: root.id,
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const posts = await postService.list({ excludeReplies: true });
|
|
316
|
+
expect(posts).toHaveLength(1);
|
|
317
|
+
expect(posts[0]?.content).toBe("root post");
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
describe("update", () => {
|
|
322
|
+
it("updates post content", async () => {
|
|
323
|
+
const post = await postService.create({
|
|
324
|
+
type: "note",
|
|
325
|
+
content: "original",
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
const updated = await postService.update(post.id, {
|
|
329
|
+
content: "updated content",
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
expect(updated).not.toBeNull();
|
|
333
|
+
expect(updated?.content).toBe("updated content");
|
|
334
|
+
expect(updated?.contentHtml).toContain("updated content");
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("updates post title", async () => {
|
|
338
|
+
const post = await postService.create({
|
|
339
|
+
type: "article",
|
|
340
|
+
content: "body",
|
|
341
|
+
title: "Original Title",
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
const updated = await postService.update(post.id, {
|
|
345
|
+
title: "New Title",
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
expect(updated?.title).toBe("New Title");
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("updates source URL and extracts domain", async () => {
|
|
352
|
+
const post = await postService.create({
|
|
353
|
+
type: "link",
|
|
354
|
+
content: "link post",
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
const updated = await postService.update(post.id, {
|
|
358
|
+
sourceUrl: "https://new-source.com/path",
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
expect(updated?.sourceUrl).toBe("https://new-source.com/path");
|
|
362
|
+
expect(updated?.sourceDomain).toBe("new-source.com");
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it("clears source domain when URL cleared", async () => {
|
|
366
|
+
const post = await postService.create({
|
|
367
|
+
type: "link",
|
|
368
|
+
content: "test",
|
|
369
|
+
sourceUrl: "https://example.com",
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
const updated = await postService.update(post.id, {
|
|
373
|
+
sourceUrl: null,
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
expect(updated?.sourceUrl).toBeNull();
|
|
377
|
+
expect(updated?.sourceDomain).toBeNull();
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it("returns null for non-existent post", async () => {
|
|
381
|
+
const result = await postService.update(9999, { content: "test" });
|
|
382
|
+
expect(result).toBeNull();
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it("updates updatedAt timestamp", async () => {
|
|
386
|
+
const post = await postService.create({
|
|
387
|
+
type: "note",
|
|
388
|
+
content: "test",
|
|
389
|
+
});
|
|
390
|
+
const originalUpdatedAt = post.updatedAt;
|
|
391
|
+
|
|
392
|
+
// Small delay to ensure different timestamp
|
|
393
|
+
await new Promise((r) => setTimeout(r, 1100));
|
|
394
|
+
|
|
395
|
+
const updated = await postService.update(post.id, {
|
|
396
|
+
content: "modified",
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
expect(updated?.updatedAt).toBeGreaterThanOrEqual(originalUpdatedAt);
|
|
400
|
+
});
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
describe("delete (soft delete)", () => {
|
|
404
|
+
it("soft-deletes a post", async () => {
|
|
405
|
+
const post = await postService.create({
|
|
406
|
+
type: "note",
|
|
407
|
+
content: "test",
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
const result = await postService.delete(post.id);
|
|
411
|
+
expect(result).toBe(true);
|
|
412
|
+
|
|
413
|
+
// Should not appear in regular queries
|
|
414
|
+
const found = await postService.getById(post.id);
|
|
415
|
+
expect(found).toBeNull();
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it("returns false for non-existent post", async () => {
|
|
419
|
+
const result = await postService.delete(9999);
|
|
420
|
+
expect(result).toBe(false);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it("cascade deletes thread when deleting root post", async () => {
|
|
424
|
+
const root = await postService.create({
|
|
425
|
+
type: "note",
|
|
426
|
+
content: "root",
|
|
427
|
+
});
|
|
428
|
+
const reply = await postService.create({
|
|
429
|
+
type: "note",
|
|
430
|
+
content: "reply",
|
|
431
|
+
replyToId: root.id,
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
await postService.delete(root.id);
|
|
435
|
+
|
|
436
|
+
// Both root and reply should be soft-deleted
|
|
437
|
+
expect(await postService.getById(root.id)).toBeNull();
|
|
438
|
+
expect(await postService.getById(reply.id)).toBeNull();
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it("only deletes single post when deleting a reply", async () => {
|
|
442
|
+
const root = await postService.create({
|
|
443
|
+
type: "note",
|
|
444
|
+
content: "root",
|
|
445
|
+
});
|
|
446
|
+
const reply1 = await postService.create({
|
|
447
|
+
type: "note",
|
|
448
|
+
content: "reply1",
|
|
449
|
+
replyToId: root.id,
|
|
450
|
+
});
|
|
451
|
+
await postService.create({
|
|
452
|
+
type: "note",
|
|
453
|
+
content: "reply2",
|
|
454
|
+
replyToId: root.id,
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
await postService.delete(reply1.id);
|
|
458
|
+
|
|
459
|
+
// Root should still exist
|
|
460
|
+
expect(await postService.getById(root.id)).not.toBeNull();
|
|
461
|
+
// reply1 should be deleted
|
|
462
|
+
expect(await postService.getById(reply1.id)).toBeNull();
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
describe("threads", () => {
|
|
467
|
+
it("sets threadId on reply to a root post", async () => {
|
|
468
|
+
const root = await postService.create({
|
|
469
|
+
type: "note",
|
|
470
|
+
content: "root",
|
|
471
|
+
});
|
|
472
|
+
const reply = await postService.create({
|
|
473
|
+
type: "note",
|
|
474
|
+
content: "reply",
|
|
475
|
+
replyToId: root.id,
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
expect(reply.threadId).toBe(root.id);
|
|
479
|
+
expect(reply.replyToId).toBe(root.id);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it("inherits threadId from parent in nested replies", async () => {
|
|
483
|
+
const root = await postService.create({
|
|
484
|
+
type: "note",
|
|
485
|
+
content: "root",
|
|
486
|
+
});
|
|
487
|
+
const reply1 = await postService.create({
|
|
488
|
+
type: "note",
|
|
489
|
+
content: "reply1",
|
|
490
|
+
replyToId: root.id,
|
|
491
|
+
});
|
|
492
|
+
const reply2 = await postService.create({
|
|
493
|
+
type: "note",
|
|
494
|
+
content: "reply2",
|
|
495
|
+
replyToId: reply1.id,
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
// Both replies point to the root's thread
|
|
499
|
+
expect(reply1.threadId).toBe(root.id);
|
|
500
|
+
expect(reply2.threadId).toBe(root.id);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it("inherits visibility from root post", async () => {
|
|
504
|
+
const root = await postService.create({
|
|
505
|
+
type: "note",
|
|
506
|
+
content: "root",
|
|
507
|
+
visibility: "featured",
|
|
508
|
+
});
|
|
509
|
+
const reply = await postService.create({
|
|
510
|
+
type: "note",
|
|
511
|
+
content: "reply",
|
|
512
|
+
replyToId: root.id,
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
expect(reply.visibility).toBe("featured");
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it("getThread returns all posts in a thread", async () => {
|
|
519
|
+
const root = await postService.create({
|
|
520
|
+
type: "note",
|
|
521
|
+
content: "root",
|
|
522
|
+
});
|
|
523
|
+
await postService.create({
|
|
524
|
+
type: "note",
|
|
525
|
+
content: "reply1",
|
|
526
|
+
replyToId: root.id,
|
|
527
|
+
});
|
|
528
|
+
await postService.create({
|
|
529
|
+
type: "note",
|
|
530
|
+
content: "reply2",
|
|
531
|
+
replyToId: root.id,
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
const thread = await postService.getThread(root.id);
|
|
535
|
+
expect(thread).toHaveLength(3);
|
|
536
|
+
// Ordered by createdAt
|
|
537
|
+
expect(thread[0]?.content).toBe("root");
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
it("getThread excludes deleted posts", async () => {
|
|
541
|
+
const root = await postService.create({
|
|
542
|
+
type: "note",
|
|
543
|
+
content: "root",
|
|
544
|
+
});
|
|
545
|
+
const reply = await postService.create({
|
|
546
|
+
type: "note",
|
|
547
|
+
content: "reply",
|
|
548
|
+
replyToId: root.id,
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
await postService.delete(reply.id);
|
|
552
|
+
|
|
553
|
+
const thread = await postService.getThread(root.id);
|
|
554
|
+
expect(thread).toHaveLength(1);
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
it("cascades visibility changes from root to thread", async () => {
|
|
558
|
+
const root = await postService.create({
|
|
559
|
+
type: "note",
|
|
560
|
+
content: "root",
|
|
561
|
+
visibility: "quiet",
|
|
562
|
+
});
|
|
563
|
+
await postService.create({
|
|
564
|
+
type: "note",
|
|
565
|
+
content: "reply",
|
|
566
|
+
replyToId: root.id,
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
await postService.update(root.id, { visibility: "featured" });
|
|
570
|
+
|
|
571
|
+
const thread = await postService.getThread(root.id);
|
|
572
|
+
for (const post of thread) {
|
|
573
|
+
expect(post.visibility).toBe("featured");
|
|
574
|
+
}
|
|
575
|
+
});
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
describe("getReplyCounts", () => {
|
|
579
|
+
it("returns empty map for empty input", async () => {
|
|
580
|
+
const counts = await postService.getReplyCounts([]);
|
|
581
|
+
expect(counts.size).toBe(0);
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
it("returns reply counts for posts", async () => {
|
|
585
|
+
const root = await postService.create({
|
|
586
|
+
type: "note",
|
|
587
|
+
content: "root",
|
|
588
|
+
});
|
|
589
|
+
await postService.create({
|
|
590
|
+
type: "note",
|
|
591
|
+
content: "reply1",
|
|
592
|
+
replyToId: root.id,
|
|
593
|
+
});
|
|
594
|
+
await postService.create({
|
|
595
|
+
type: "note",
|
|
596
|
+
content: "reply2",
|
|
597
|
+
replyToId: root.id,
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
const counts = await postService.getReplyCounts([root.id]);
|
|
601
|
+
expect(counts.get(root.id)).toBe(2);
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
it("returns 0 (missing) for posts without replies", async () => {
|
|
605
|
+
const post = await postService.create({
|
|
606
|
+
type: "note",
|
|
607
|
+
content: "no replies",
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
const counts = await postService.getReplyCounts([post.id]);
|
|
611
|
+
expect(counts.get(post.id)).toBeUndefined();
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
it("excludes deleted replies from count", async () => {
|
|
615
|
+
const root = await postService.create({
|
|
616
|
+
type: "note",
|
|
617
|
+
content: "root",
|
|
618
|
+
});
|
|
619
|
+
const reply = await postService.create({
|
|
620
|
+
type: "note",
|
|
621
|
+
content: "reply",
|
|
622
|
+
replyToId: root.id,
|
|
623
|
+
});
|
|
624
|
+
await postService.create({
|
|
625
|
+
type: "note",
|
|
626
|
+
content: "reply2",
|
|
627
|
+
replyToId: root.id,
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
await postService.delete(reply.id);
|
|
631
|
+
|
|
632
|
+
const counts = await postService.getReplyCounts([root.id]);
|
|
633
|
+
expect(counts.get(root.id)).toBe(1);
|
|
634
|
+
});
|
|
635
|
+
});
|
|
636
|
+
});
|