@jant/core 0.3.36 → 0.3.37
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/bin/commands/export.js +1 -1
- package/bin/commands/import-site.js +529 -0
- package/bin/commands/reset-password.js +3 -2
- package/dist/client/assets/heic-to-DIRPI3VF.js +1 -0
- package/dist/client/assets/url-FWFqPJPb.js +1 -0
- package/dist/client/client.css +1 -1
- package/dist/client/client.js +4012 -3276
- package/dist/index.js +10285 -5809
- package/package.json +11 -3
- package/src/__tests__/helpers/app.ts +9 -9
- package/src/__tests__/helpers/db.ts +91 -93
- package/src/app.tsx +157 -27
- package/src/auth.ts +20 -2
- package/src/client/archive-nav.js +187 -0
- package/src/client/audio-player.ts +478 -0
- package/src/client/audio-processor.ts +84 -0
- package/src/client/avatar-upload.ts +3 -2
- package/src/client/components/__tests__/jant-compose-dialog.test.ts +645 -49
- package/src/client/components/__tests__/jant-compose-editor.test.ts +208 -16
- package/src/client/components/__tests__/jant-post-form.test.ts +19 -9
- package/src/client/components/__tests__/jant-settings-avatar.test.ts +2 -2
- package/src/client/components/__tests__/jant-settings-general.test.ts +3 -3
- package/src/client/components/collection-sidebar-types.ts +7 -9
- package/src/client/components/compose-types.ts +101 -4
- package/src/client/components/jant-collection-form.ts +43 -7
- package/src/client/components/jant-collection-sidebar.ts +88 -84
- package/src/client/components/jant-compose-dialog.ts +1655 -219
- package/src/client/components/jant-compose-editor.ts +732 -168
- package/src/client/components/jant-compose-fullscreen.ts +23 -78
- package/src/client/components/jant-media-lightbox.ts +2 -0
- package/src/client/components/jant-nav-manager.ts +24 -284
- package/src/client/components/jant-post-form.ts +89 -9
- package/src/client/components/jant-post-menu.ts +1019 -0
- package/src/client/components/jant-settings-avatar.ts +3 -3
- package/src/client/components/jant-settings-general.ts +38 -4
- package/src/client/components/jant-text-preview.ts +232 -0
- package/src/client/components/nav-manager-types.ts +4 -19
- package/src/client/components/post-form-template.ts +107 -12
- package/src/client/components/post-form-types.ts +11 -4
- package/src/client/compose-bridge.ts +410 -109
- package/src/client/image-processor.ts +26 -8
- package/src/client/media-metadata.ts +247 -0
- package/src/client/multipart-upload.ts +160 -0
- package/src/client/post-form-bridge.ts +52 -1
- package/src/client/settings-bridge.ts +0 -12
- package/src/client/thread-context.ts +140 -0
- package/src/client/tiptap/create-editor.ts +46 -0
- package/src/client/tiptap/extensions.ts +5 -0
- package/src/client/tiptap/image-node.ts +2 -8
- package/src/client/tiptap/paste-image.ts +2 -13
- package/src/client/tiptap/slash-commands.ts +173 -63
- package/src/client/toast.ts +101 -3
- package/src/client/types/sortablejs.d.ts +15 -0
- package/src/client/upload-with-metadata.ts +54 -0
- package/src/client/video-processor.ts +207 -0
- package/src/client.ts +5 -2
- package/src/db/__tests__/migrations.test.ts +118 -0
- package/src/db/index.ts +52 -0
- package/src/db/migrations/0000_baseline.sql +269 -0
- package/src/db/migrations/0001_fts_setup.sql +31 -0
- package/src/db/migrations/meta/0000_snapshot.json +703 -119
- package/src/db/migrations/meta/0001_snapshot.json +1337 -0
- package/src/db/migrations/meta/_journal.json +4 -39
- package/src/db/schema.ts +409 -145
- package/src/i18n/__tests__/detect.test.ts +115 -0
- package/src/i18n/context.tsx +2 -2
- package/src/i18n/detect.ts +85 -1
- package/src/i18n/i18n.ts +1 -1
- package/src/i18n/index.ts +2 -1
- package/src/i18n/locales/en.po +487 -1217
- package/src/i18n/locales/en.ts +1 -1
- package/src/i18n/locales/zh-Hans.po +613 -996
- package/src/i18n/locales/zh-Hans.ts +1 -1
- package/src/i18n/locales/zh-Hant.po +624 -1007
- package/src/i18n/locales/zh-Hant.ts +1 -1
- package/src/i18n/middleware.ts +6 -0
- package/src/index.ts +5 -7
- package/src/lib/__tests__/blurhash-placeholder.test.ts +75 -0
- package/src/lib/__tests__/constants.test.ts +0 -1
- package/src/lib/__tests__/markdown-to-tiptap.test.ts +358 -0
- package/src/lib/__tests__/nanoid.test.ts +26 -0
- package/src/lib/__tests__/schemas.test.ts +181 -63
- package/src/lib/__tests__/slug.test.ts +126 -0
- package/src/lib/__tests__/sse.test.ts +6 -6
- package/src/lib/__tests__/summary.test.ts +264 -0
- package/src/lib/__tests__/theme.test.ts +1 -1
- package/src/lib/__tests__/timeline.test.ts +33 -30
- package/src/lib/__tests__/tiptap-to-markdown.test.ts +346 -0
- package/src/lib/__tests__/view.test.ts +141 -66
- package/src/lib/blurhash-placeholder.ts +102 -0
- package/src/lib/constants.ts +3 -1
- package/src/lib/emoji-catalog.ts +885 -68
- package/src/lib/errors.ts +11 -8
- package/src/lib/feed.ts +78 -32
- package/src/lib/html.ts +2 -1
- package/src/lib/icon-catalog.ts +5033 -1
- package/src/lib/icons.ts +3 -2
- package/src/lib/index.ts +0 -1
- package/src/lib/markdown-to-tiptap.ts +286 -0
- package/src/lib/media-helpers.ts +12 -3
- package/src/lib/nanoid.ts +29 -0
- package/src/lib/navigation.ts +1 -1
- package/src/lib/render.tsx +20 -2
- package/src/lib/resolve-config.ts +6 -2
- package/src/lib/schemas.ts +224 -55
- package/src/lib/search-snippet.ts +34 -0
- package/src/lib/slug.ts +96 -0
- package/src/lib/sse.ts +6 -6
- package/src/lib/storage.ts +115 -7
- package/src/lib/summary.ts +66 -0
- package/src/lib/theme.ts +11 -8
- package/src/lib/timeline.ts +74 -34
- package/src/lib/tiptap-render.ts +5 -10
- package/src/lib/tiptap-to-markdown.ts +305 -0
- package/src/lib/upload.ts +190 -29
- package/src/lib/url.ts +31 -0
- package/src/lib/view.ts +204 -37
- package/src/middleware/__tests__/auth.test.ts +191 -11
- package/src/middleware/__tests__/onboarding.test.ts +12 -10
- package/src/middleware/auth.ts +63 -9
- package/src/middleware/onboarding.ts +1 -1
- package/src/middleware/secure-headers.ts +40 -0
- package/src/preset.css +45 -2
- package/src/routes/__tests__/compose.test.ts +17 -24
- package/src/routes/api/__tests__/collections.test.ts +109 -61
- package/src/routes/api/__tests__/nav-items.test.ts +46 -29
- package/src/routes/api/__tests__/posts.test.ts +132 -68
- package/src/routes/api/__tests__/search.test.ts +15 -2
- package/src/routes/api/__tests__/upload-multipart.test.ts +534 -0
- package/src/routes/api/collections.ts +51 -42
- package/src/routes/api/custom-urls.ts +80 -0
- package/src/routes/api/export.ts +31 -0
- package/src/routes/api/nav-items.ts +23 -19
- package/src/routes/api/posts.ts +43 -39
- package/src/routes/api/search.ts +3 -4
- package/src/routes/api/upload-multipart.ts +245 -0
- package/src/routes/api/upload.ts +85 -19
- package/src/routes/auth/__tests__/setup.test.ts +20 -60
- package/src/routes/auth/setup.tsx +26 -33
- package/src/routes/auth/signin.tsx +3 -7
- package/src/routes/compose.tsx +10 -55
- package/src/routes/dash/__tests__/settings-avatar.test.ts +1 -1
- package/src/routes/dash/custom-urls.tsx +414 -0
- package/src/routes/dash/settings.tsx +304 -232
- package/src/routes/feed/__tests__/rss.test.ts +27 -28
- package/src/routes/feed/rss.ts +6 -4
- package/src/routes/feed/sitemap.ts +2 -12
- package/src/routes/pages/__tests__/collections.test.ts +5 -6
- package/src/routes/pages/__tests__/featured.test.ts +41 -22
- package/src/routes/pages/archive.tsx +175 -39
- package/src/routes/pages/collection.tsx +22 -10
- package/src/routes/pages/collections.tsx +3 -3
- package/src/routes/pages/featured.tsx +28 -4
- package/src/routes/pages/home.tsx +16 -15
- package/src/routes/pages/latest.tsx +1 -11
- package/src/routes/pages/new.tsx +39 -0
- package/src/routes/pages/page.tsx +95 -49
- package/src/routes/pages/search.tsx +1 -1
- package/src/services/__tests__/api-token.test.ts +135 -0
- package/src/services/__tests__/collection.test.ts +275 -227
- package/src/services/__tests__/custom-url.test.ts +213 -0
- package/src/services/__tests__/media.test.ts +162 -22
- package/src/services/__tests__/navigation.test.ts +109 -68
- package/src/services/__tests__/post-timeline.test.ts +205 -32
- package/src/services/__tests__/post.test.ts +713 -234
- package/src/services/__tests__/search.test.ts +67 -10
- package/src/services/api-token.ts +166 -0
- package/src/services/auth.ts +17 -2
- package/src/services/collection.ts +397 -131
- package/src/services/custom-url.ts +188 -0
- package/src/services/export.ts +802 -0
- package/src/services/index.ts +26 -19
- package/src/services/media.ts +100 -22
- package/src/services/navigation.ts +158 -47
- package/src/services/path.ts +339 -0
- package/src/services/post.ts +687 -154
- package/src/services/search.ts +160 -75
- package/src/styles/components.css +58 -7
- package/src/styles/tokens.css +84 -6
- package/src/styles/ui.css +2964 -457
- package/src/types/bindings.ts +4 -1
- package/src/types/config.ts +12 -4
- package/src/types/constants.ts +15 -3
- package/src/types/entities.ts +74 -35
- package/src/types/operations.ts +11 -24
- package/src/types/props.ts +51 -16
- package/src/types/views.ts +45 -22
- package/src/ui/color-themes.ts +133 -23
- package/src/ui/compose/ComposeDialog.tsx +239 -17
- package/src/ui/compose/ComposePrompt.tsx +1 -1
- package/src/ui/dash/CrudPageHeader.tsx +1 -1
- package/src/ui/dash/ListItemRow.tsx +1 -1
- package/src/ui/dash/StatusBadge.tsx +3 -1
- package/src/ui/dash/appearance/AdvancedContent.tsx +22 -1
- package/src/ui/dash/appearance/ColorThemeContent.tsx +22 -2
- package/src/ui/dash/appearance/FontThemeContent.tsx +1 -1
- package/src/ui/dash/appearance/NavigationContent.tsx +5 -45
- package/src/ui/dash/index.ts +0 -3
- package/src/ui/dash/settings/AccountContent.tsx +3 -57
- package/src/ui/dash/settings/AccountMenuContent.tsx +147 -0
- package/src/ui/dash/settings/ApiTokensContent.tsx +232 -0
- package/src/ui/dash/settings/AvatarContent.tsx +8 -0
- package/src/ui/dash/settings/SessionsContent.tsx +159 -0
- package/src/ui/dash/settings/SettingsRootContent.tsx +45 -15
- package/src/ui/feed/LinkCard.tsx +89 -40
- package/src/ui/feed/NoteCard.tsx +39 -25
- package/src/ui/feed/PostStatusBadges.tsx +67 -0
- package/src/ui/feed/QuoteCard.tsx +38 -23
- package/src/ui/feed/ThreadPreview.tsx +90 -26
- package/src/ui/feed/TimelineFeed.tsx +3 -2
- package/src/ui/feed/TimelineItem.tsx +15 -6
- package/src/ui/feed/__tests__/thread-preview.test.ts +107 -0
- package/src/ui/feed/thread-preview-state.ts +61 -0
- package/src/ui/font-themes.ts +2 -2
- package/src/ui/layouts/BaseLayout.tsx +2 -2
- package/src/ui/layouts/SiteLayout.tsx +105 -92
- package/src/ui/pages/ArchivePage.tsx +923 -98
- package/src/ui/pages/ComposePage.tsx +54 -0
- package/src/ui/pages/PostPage.tsx +30 -45
- package/src/ui/pages/SearchPage.tsx +181 -37
- package/src/ui/shared/AdminBreadcrumb.tsx +29 -0
- package/src/ui/shared/CollectionsSidebar.tsx +47 -37
- package/src/ui/shared/MediaGallery.tsx +445 -149
- package/src/ui/shared/PostFooter.tsx +204 -0
- package/src/ui/shared/StarRating.tsx +27 -0
- package/src/ui/shared/__tests__/format-chars.test.ts +35 -0
- package/src/ui/shared/index.ts +0 -1
- package/dist/client/assets/url-8Dj-5CLW.js +0 -1
- package/src/client/media-upload.ts +0 -161
- package/src/client/page-slug-bridge.ts +0 -42
- package/src/db/migrations/0000_square_wallflower.sql +0 -118
- package/src/db/migrations/0001_add_search_fts.sql +0 -34
- package/src/db/migrations/0002_add_media_attachments.sql +0 -3
- package/src/db/migrations/0003_add_navigation_links.sql +0 -8
- package/src/db/migrations/0004_add_storage_provider.sql +0 -3
- package/src/db/migrations/0005_v2_schema_migration.sql +0 -268
- package/src/db/migrations/0006_rename_slug_to_path.sql +0 -5
- package/src/db/migrations/0007_post_collections_m2m.sql +0 -94
- package/src/db/migrations/0008_add_collection_dividers.sql +0 -8
- package/src/db/migrations/0009_drop_collection_show_divider.sql +0 -2
- package/src/db/migrations/0010_add_performance_indexes.sql +0 -16
- package/src/db/migrations/0011_add_path_registry.sql +0 -23
- package/src/db/migrations/0012_add_tiptap_columns.sql +0 -2
- package/src/db/migrations/0013_replace_featured_with_visibility.sql +0 -8
- package/src/db/migrations/meta/0003_snapshot.json +0 -821
- package/src/lib/__tests__/sqid.test.ts +0 -65
- package/src/lib/sqid.ts +0 -79
- package/src/routes/api/__tests__/pages.test.ts +0 -218
- package/src/routes/api/pages.ts +0 -73
- package/src/routes/dash/__tests__/pages.test.ts +0 -226
- package/src/routes/dash/index.tsx +0 -109
- package/src/routes/dash/media.tsx +0 -135
- package/src/routes/dash/pages.tsx +0 -245
- package/src/routes/dash/posts.tsx +0 -338
- package/src/routes/dash/redirects.tsx +0 -263
- package/src/routes/pages/post.tsx +0 -59
- package/src/services/__tests__/page.test.ts +0 -298
- package/src/services/__tests__/path-registry.test.ts +0 -165
- package/src/services/__tests__/redirect.test.ts +0 -159
- package/src/services/page.ts +0 -216
- package/src/services/path-registry.ts +0 -160
- package/src/services/redirect.ts +0 -97
- package/src/ui/dash/PageForm.tsx +0 -187
- package/src/ui/dash/PostList.tsx +0 -95
- package/src/ui/dash/media/MediaListContent.tsx +0 -206
- package/src/ui/dash/media/ViewMediaContent.tsx +0 -208
- package/src/ui/dash/pages/PagesContent.tsx +0 -75
- package/src/ui/dash/posts/PostForm.tsx +0 -260
- package/src/ui/layouts/DashLayout.tsx +0 -247
- package/src/ui/pages/SinglePage.tsx +0 -23
- package/src/ui/shared/ThreadView.tsx +0 -136
|
@@ -1,23 +1,19 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
import { eq } from "drizzle-orm";
|
|
2
3
|
import { createTestDatabase } from "../../__tests__/helpers/db.js";
|
|
4
|
+
import { posts } from "../../db/schema.js";
|
|
3
5
|
import { createPostService } from "../post.js";
|
|
4
|
-
import { createPageService } from "../page.js";
|
|
5
|
-
import { createPathRegistryService } from "../path-registry.js";
|
|
6
|
-
import { ValidationError, ConflictError } from "../../lib/errors.js";
|
|
7
6
|
import type { Database } from "../../db/index.js";
|
|
7
|
+
import { createPathService } from "../path.js";
|
|
8
8
|
|
|
9
9
|
describe("PostService", () => {
|
|
10
10
|
let db: Database;
|
|
11
11
|
let postService: ReturnType<typeof createPostService>;
|
|
12
|
-
let pageService: ReturnType<typeof createPageService>;
|
|
13
|
-
let pathRegistry: ReturnType<typeof createPathRegistryService>;
|
|
14
12
|
|
|
15
13
|
beforeEach(() => {
|
|
16
14
|
const testDb = createTestDatabase();
|
|
17
15
|
db = testDb.db as unknown as Database;
|
|
18
|
-
|
|
19
|
-
postService = createPostService(db, pathRegistry);
|
|
20
|
-
pageService = createPageService(db, pathRegistry);
|
|
16
|
+
postService = createPostService(db, { slugIdLength: 5 });
|
|
21
17
|
});
|
|
22
18
|
|
|
23
19
|
describe("create", () => {
|
|
@@ -36,17 +32,21 @@ describe("PostService", () => {
|
|
|
36
32
|
body,
|
|
37
33
|
});
|
|
38
34
|
|
|
39
|
-
expect(post.id).toBe(
|
|
35
|
+
expect(typeof post.id).toBe("string");
|
|
36
|
+
expect(post.id).toMatch(
|
|
37
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
|
|
38
|
+
);
|
|
40
39
|
expect(post.format).toBe("note");
|
|
41
40
|
expect(post.body).toBe(body);
|
|
42
41
|
expect(post.status).toBe("published"); // default
|
|
43
|
-
expect(post.visibility).toBe("
|
|
44
|
-
expect(post.
|
|
42
|
+
expect(post.visibility).toBe("public");
|
|
43
|
+
expect(post.pinnedAt).toBeNull();
|
|
45
44
|
expect(post.bodyHtml).toContain("<p>Hello world</p>");
|
|
46
45
|
expect(post.deletedAt).toBeNull();
|
|
46
|
+
expect(post.threadId).toBe(post.id);
|
|
47
47
|
});
|
|
48
48
|
|
|
49
|
-
it("creates a post with
|
|
49
|
+
it("creates a link post with commentary", async () => {
|
|
50
50
|
const body = JSON.stringify({
|
|
51
51
|
type: "doc",
|
|
52
52
|
content: [
|
|
@@ -66,22 +66,23 @@ describe("PostService", () => {
|
|
|
66
66
|
title: "My Link",
|
|
67
67
|
body,
|
|
68
68
|
status: "published",
|
|
69
|
-
visibility: "
|
|
69
|
+
visibility: "public",
|
|
70
|
+
featured: true,
|
|
70
71
|
pinned: true,
|
|
71
|
-
|
|
72
|
+
slug: "my-link",
|
|
72
73
|
url: "https://example.com/source",
|
|
73
|
-
quoteText: "A notable quote",
|
|
74
74
|
rating: 5,
|
|
75
75
|
});
|
|
76
76
|
|
|
77
77
|
expect(post.format).toBe("link");
|
|
78
78
|
expect(post.title).toBe("My Link");
|
|
79
79
|
expect(post.status).toBe("published");
|
|
80
|
-
expect(post.visibility).toBe("
|
|
81
|
-
expect(post.
|
|
82
|
-
expect(post.
|
|
80
|
+
expect(post.visibility).toBe("public");
|
|
81
|
+
expect(post.featuredAt).toBeTypeOf("number");
|
|
82
|
+
expect(post.pinnedAt).toBeTypeOf("number");
|
|
83
|
+
expect(post.slug).toBe("my-link");
|
|
83
84
|
expect(post.url).toBe("https://example.com/source");
|
|
84
|
-
expect(post.quoteText).
|
|
85
|
+
expect(post.quoteText).toBeNull();
|
|
85
86
|
expect(post.rating).toBe(5);
|
|
86
87
|
expect(post.bodyHtml).toContain("<h1>");
|
|
87
88
|
});
|
|
@@ -115,7 +116,7 @@ describe("PostService", () => {
|
|
|
115
116
|
it("sets publishedAt and timestamps", async () => {
|
|
116
117
|
const post = await postService.create({
|
|
117
118
|
format: "note",
|
|
118
|
-
|
|
119
|
+
bodyMarkdown: "test",
|
|
119
120
|
});
|
|
120
121
|
|
|
121
122
|
expect(post.publishedAt).toBeGreaterThan(0);
|
|
@@ -127,31 +128,33 @@ describe("PostService", () => {
|
|
|
127
128
|
const customTime = 1706745600;
|
|
128
129
|
const post = await postService.create({
|
|
129
130
|
format: "note",
|
|
130
|
-
|
|
131
|
+
bodyMarkdown: "test",
|
|
131
132
|
publishedAt: customTime,
|
|
132
133
|
});
|
|
133
134
|
|
|
134
135
|
expect(post.publishedAt).toBe(customTime);
|
|
135
136
|
});
|
|
136
137
|
|
|
137
|
-
it("creates
|
|
138
|
+
it("creates unique UUIDv7 IDs that sort chronologically", async () => {
|
|
138
139
|
const post1 = await postService.create({
|
|
139
140
|
format: "note",
|
|
140
|
-
|
|
141
|
+
bodyMarkdown: "first",
|
|
141
142
|
});
|
|
142
143
|
const post2 = await postService.create({
|
|
143
144
|
format: "note",
|
|
144
|
-
|
|
145
|
+
bodyMarkdown: "second",
|
|
145
146
|
});
|
|
146
147
|
|
|
147
|
-
expect(
|
|
148
|
+
expect(post1.id).not.toBe(post2.id);
|
|
149
|
+
// UUIDv7 strings sort chronologically
|
|
150
|
+
expect(post2.id > post1.id).toBe(true);
|
|
148
151
|
});
|
|
149
152
|
|
|
150
153
|
it("creates a quote post", async () => {
|
|
151
154
|
const post = await postService.create({
|
|
152
155
|
format: "quote",
|
|
153
156
|
quoteText: "To be or not to be",
|
|
154
|
-
|
|
157
|
+
bodyMarkdown: "Shakespeare's famous line",
|
|
155
158
|
url: "https://example.com/hamlet",
|
|
156
159
|
});
|
|
157
160
|
|
|
@@ -163,11 +166,110 @@ describe("PostService", () => {
|
|
|
163
166
|
it("creates a draft post", async () => {
|
|
164
167
|
const post = await postService.create({
|
|
165
168
|
format: "note",
|
|
166
|
-
|
|
169
|
+
bodyMarkdown: "draft content",
|
|
167
170
|
status: "draft",
|
|
168
171
|
});
|
|
169
172
|
|
|
170
173
|
expect(post.status).toBe("draft");
|
|
174
|
+
expect(post.publishedAt).toBeNull();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("rejects ratings outside the database range", async () => {
|
|
178
|
+
await expect(
|
|
179
|
+
postService.create({
|
|
180
|
+
format: "note",
|
|
181
|
+
bodyMarkdown: "test",
|
|
182
|
+
rating: 6,
|
|
183
|
+
}),
|
|
184
|
+
).rejects.toThrow();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("rejects draft posts with an explicit publish time", async () => {
|
|
188
|
+
await expect(
|
|
189
|
+
postService.create({
|
|
190
|
+
format: "note",
|
|
191
|
+
bodyMarkdown: "draft content",
|
|
192
|
+
status: "draft",
|
|
193
|
+
publishedAt: 1706745600,
|
|
194
|
+
}),
|
|
195
|
+
).rejects.toThrow("Drafts can't set a publish time.");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("rejects note posts with a URL", async () => {
|
|
199
|
+
await expect(
|
|
200
|
+
postService.create({
|
|
201
|
+
format: "note",
|
|
202
|
+
url: "https://example.com",
|
|
203
|
+
}),
|
|
204
|
+
).rejects.toThrow("Notes can't include a URL.");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("rejects link posts without a URL", async () => {
|
|
208
|
+
await expect(
|
|
209
|
+
postService.create({
|
|
210
|
+
format: "link",
|
|
211
|
+
bodyMarkdown: "commentary",
|
|
212
|
+
}),
|
|
213
|
+
).rejects.toThrow("Link posts need a URL.");
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("rejects link posts with quoted text", async () => {
|
|
217
|
+
await expect(
|
|
218
|
+
postService.create({
|
|
219
|
+
format: "link",
|
|
220
|
+
url: "https://example.com",
|
|
221
|
+
quoteText: "A notable quote",
|
|
222
|
+
}),
|
|
223
|
+
).rejects.toThrow("Link posts can't include quoted text.");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("rejects quote posts without quoted text", async () => {
|
|
227
|
+
await expect(
|
|
228
|
+
postService.create({
|
|
229
|
+
format: "quote",
|
|
230
|
+
bodyMarkdown: "commentary",
|
|
231
|
+
}),
|
|
232
|
+
).rejects.toThrow("Quote posts need quoted text.");
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("rejects replies to missing posts", async () => {
|
|
236
|
+
await expect(
|
|
237
|
+
postService.create({
|
|
238
|
+
format: "note",
|
|
239
|
+
bodyMarkdown: "reply",
|
|
240
|
+
replyToId: "00000000-0000-0000-0000-000000009999",
|
|
241
|
+
}),
|
|
242
|
+
).rejects.toThrow("Parent post not found");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("rolls back the post insert when slug persistence fails inside the batch", async () => {
|
|
246
|
+
await postService.create({
|
|
247
|
+
format: "note",
|
|
248
|
+
bodyMarkdown: "existing",
|
|
249
|
+
slug: "race-condition",
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
const paths = createPathService(db);
|
|
253
|
+
const raceyPaths = {
|
|
254
|
+
...paths,
|
|
255
|
+
isPathAvailable: async () => true,
|
|
256
|
+
};
|
|
257
|
+
const raceyPostService = createPostService(
|
|
258
|
+
db,
|
|
259
|
+
{ slugIdLength: 5 },
|
|
260
|
+
raceyPaths,
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
await expect(
|
|
264
|
+
raceyPostService.create({
|
|
265
|
+
format: "note",
|
|
266
|
+
bodyMarkdown: "test",
|
|
267
|
+
slug: "race-condition",
|
|
268
|
+
}),
|
|
269
|
+
).rejects.toThrow('Slug "race-condition" is already in use');
|
|
270
|
+
|
|
271
|
+
const rows = await db.select({ id: posts.id }).from(posts);
|
|
272
|
+
expect(rows).toHaveLength(1);
|
|
171
273
|
});
|
|
172
274
|
});
|
|
173
275
|
|
|
@@ -175,13 +277,13 @@ describe("PostService", () => {
|
|
|
175
277
|
it("returns a post by ID", async () => {
|
|
176
278
|
const created = await postService.create({
|
|
177
279
|
format: "note",
|
|
178
|
-
|
|
280
|
+
bodyMarkdown: "test",
|
|
179
281
|
});
|
|
180
282
|
|
|
181
283
|
const found = await postService.getById(created.id);
|
|
182
284
|
expect(found).not.toBeNull();
|
|
183
285
|
expect(found?.id).toBe(created.id);
|
|
184
|
-
expect(found?.
|
|
286
|
+
expect(found?.bodyText).toBe("test");
|
|
185
287
|
});
|
|
186
288
|
|
|
187
289
|
it("returns null for non-existent ID", async () => {
|
|
@@ -192,7 +294,7 @@ describe("PostService", () => {
|
|
|
192
294
|
it("excludes soft-deleted posts", async () => {
|
|
193
295
|
const post = await postService.create({
|
|
194
296
|
format: "note",
|
|
195
|
-
|
|
297
|
+
bodyMarkdown: "test",
|
|
196
298
|
});
|
|
197
299
|
await postService.delete(post.id);
|
|
198
300
|
|
|
@@ -201,47 +303,35 @@ describe("PostService", () => {
|
|
|
201
303
|
});
|
|
202
304
|
});
|
|
203
305
|
|
|
204
|
-
describe("
|
|
205
|
-
it("returns a post by
|
|
306
|
+
describe("getBySlug", () => {
|
|
307
|
+
it("returns a post by slug", async () => {
|
|
206
308
|
await postService.create({
|
|
207
309
|
format: "note",
|
|
208
|
-
|
|
209
|
-
|
|
310
|
+
bodyMarkdown: "About page",
|
|
311
|
+
slug: "about",
|
|
210
312
|
});
|
|
211
313
|
|
|
212
|
-
const found = await postService.
|
|
314
|
+
const found = await postService.getBySlug("about");
|
|
213
315
|
expect(found).not.toBeNull();
|
|
214
|
-
expect(found?.
|
|
316
|
+
expect(found?.slug).toBe("about");
|
|
215
317
|
});
|
|
216
318
|
|
|
217
|
-
it("returns null for non-existent
|
|
218
|
-
const found = await postService.
|
|
319
|
+
it("returns null for non-existent slug", async () => {
|
|
320
|
+
const found = await postService.getBySlug("nonexistent");
|
|
219
321
|
expect(found).toBeNull();
|
|
220
322
|
});
|
|
221
323
|
|
|
222
324
|
it("excludes soft-deleted posts", async () => {
|
|
223
325
|
const post = await postService.create({
|
|
224
326
|
format: "note",
|
|
225
|
-
|
|
226
|
-
|
|
327
|
+
bodyMarkdown: "test",
|
|
328
|
+
slug: "test-page",
|
|
227
329
|
});
|
|
228
330
|
await postService.delete(post.id);
|
|
229
331
|
|
|
230
|
-
const found = await postService.
|
|
332
|
+
const found = await postService.getBySlug("test-page");
|
|
231
333
|
expect(found).toBeNull();
|
|
232
334
|
});
|
|
233
|
-
|
|
234
|
-
it("finds a post with a multi-level path", async () => {
|
|
235
|
-
await postService.create({
|
|
236
|
-
format: "note",
|
|
237
|
-
body: "Blog migration",
|
|
238
|
-
path: "2024/01/my-post",
|
|
239
|
-
});
|
|
240
|
-
|
|
241
|
-
const found = await postService.getByPath("2024/01/my-post");
|
|
242
|
-
expect(found).not.toBeNull();
|
|
243
|
-
expect(found?.path).toBe("2024/01/my-post");
|
|
244
|
-
});
|
|
245
335
|
});
|
|
246
336
|
|
|
247
337
|
describe("list", () => {
|
|
@@ -251,9 +341,9 @@ describe("PostService", () => {
|
|
|
251
341
|
});
|
|
252
342
|
|
|
253
343
|
it("returns all non-deleted posts", async () => {
|
|
254
|
-
await postService.create({ format: "note",
|
|
255
|
-
await postService.create({ format: "note",
|
|
256
|
-
await postService.create({ format: "note",
|
|
344
|
+
await postService.create({ format: "note", bodyMarkdown: "first" });
|
|
345
|
+
await postService.create({ format: "note", bodyMarkdown: "second" });
|
|
346
|
+
await postService.create({ format: "note", bodyMarkdown: "third" });
|
|
257
347
|
|
|
258
348
|
const posts = await postService.list();
|
|
259
349
|
expect(posts).toHaveLength(3);
|
|
@@ -262,25 +352,50 @@ describe("PostService", () => {
|
|
|
262
352
|
it("orders by publishedAt descending", async () => {
|
|
263
353
|
await postService.create({
|
|
264
354
|
format: "note",
|
|
265
|
-
|
|
355
|
+
bodyMarkdown: "old",
|
|
266
356
|
publishedAt: 1000,
|
|
267
357
|
});
|
|
268
358
|
await postService.create({
|
|
269
359
|
format: "note",
|
|
270
|
-
|
|
360
|
+
bodyMarkdown: "new",
|
|
271
361
|
publishedAt: 2000,
|
|
272
362
|
});
|
|
273
363
|
|
|
274
364
|
const posts = await postService.list();
|
|
275
|
-
expect(posts[0]?.
|
|
276
|
-
expect(posts[1]?.
|
|
365
|
+
expect(posts[0]?.bodyText).toBe("new");
|
|
366
|
+
expect(posts[1]?.bodyText).toBe("old");
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it("orders drafts by updatedAt descending", async () => {
|
|
370
|
+
const older = await postService.create({
|
|
371
|
+
format: "note",
|
|
372
|
+
bodyMarkdown: "older draft",
|
|
373
|
+
status: "draft",
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
await new Promise((r) => setTimeout(r, 1100));
|
|
377
|
+
|
|
378
|
+
const newer = await postService.create({
|
|
379
|
+
format: "note",
|
|
380
|
+
bodyMarkdown: "newer draft",
|
|
381
|
+
status: "draft",
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
await new Promise((r) => setTimeout(r, 1100));
|
|
385
|
+
await postService.update(older.id, {
|
|
386
|
+
bodyMarkdown: "older draft edited",
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
const drafts = await postService.list({ status: "draft" });
|
|
390
|
+
expect(drafts[0]?.id).toBe(older.id);
|
|
391
|
+
expect(drafts[1]?.id).toBe(newer.id);
|
|
277
392
|
});
|
|
278
393
|
|
|
279
394
|
it("filters by format", async () => {
|
|
280
|
-
await postService.create({ format: "note",
|
|
395
|
+
await postService.create({ format: "note", bodyMarkdown: "a note" });
|
|
281
396
|
await postService.create({
|
|
282
397
|
format: "link",
|
|
283
|
-
|
|
398
|
+
bodyMarkdown: "a link",
|
|
284
399
|
title: "Link",
|
|
285
400
|
url: "https://example.com",
|
|
286
401
|
});
|
|
@@ -293,12 +408,12 @@ describe("PostService", () => {
|
|
|
293
408
|
it("filters by status", async () => {
|
|
294
409
|
await postService.create({
|
|
295
410
|
format: "note",
|
|
296
|
-
|
|
411
|
+
bodyMarkdown: "published post",
|
|
297
412
|
status: "published",
|
|
298
413
|
});
|
|
299
414
|
await postService.create({
|
|
300
415
|
format: "note",
|
|
301
|
-
|
|
416
|
+
bodyMarkdown: "draft post",
|
|
302
417
|
status: "draft",
|
|
303
418
|
});
|
|
304
419
|
|
|
@@ -310,98 +425,146 @@ describe("PostService", () => {
|
|
|
310
425
|
it("filters by visibility", async () => {
|
|
311
426
|
await postService.create({
|
|
312
427
|
format: "note",
|
|
313
|
-
|
|
314
|
-
visibility: "featured",
|
|
428
|
+
bodyMarkdown: "public post",
|
|
315
429
|
});
|
|
316
430
|
await postService.create({
|
|
317
431
|
format: "note",
|
|
318
|
-
|
|
432
|
+
bodyMarkdown: "unlisted post",
|
|
433
|
+
visibility: "unlisted",
|
|
319
434
|
});
|
|
320
435
|
await postService.create({
|
|
321
436
|
format: "note",
|
|
322
|
-
|
|
323
|
-
visibility: "
|
|
437
|
+
bodyMarkdown: "private post",
|
|
438
|
+
visibility: "private",
|
|
324
439
|
});
|
|
325
440
|
|
|
326
|
-
const
|
|
327
|
-
expect(
|
|
328
|
-
expect(
|
|
329
|
-
expect(
|
|
330
|
-
|
|
331
|
-
const listed = await postService.list({ visibility: "listed" });
|
|
332
|
-
expect(listed).toHaveLength(1);
|
|
333
|
-
expect(listed[0]?.visibility).toBe("listed");
|
|
334
|
-
expect(listed[0]?.body).toBe("normal post");
|
|
441
|
+
const publicPosts = await postService.list({ visibility: "public" });
|
|
442
|
+
expect(publicPosts).toHaveLength(1);
|
|
443
|
+
expect(publicPosts[0]?.visibility).toBe("public");
|
|
444
|
+
expect(publicPosts[0]?.bodyText).toBe("public post");
|
|
335
445
|
|
|
336
446
|
const unlisted = await postService.list({ visibility: "unlisted" });
|
|
337
447
|
expect(unlisted).toHaveLength(1);
|
|
338
448
|
expect(unlisted[0]?.visibility).toBe("unlisted");
|
|
339
|
-
expect(unlisted[0]?.
|
|
449
|
+
expect(unlisted[0]?.bodyText).toBe("unlisted post");
|
|
450
|
+
|
|
451
|
+
const privatePosts = await postService.list({ visibility: "private" });
|
|
452
|
+
expect(privatePosts).toHaveLength(1);
|
|
453
|
+
expect(privatePosts[0]?.visibility).toBe("private");
|
|
454
|
+
expect(privatePosts[0]?.bodyText).toBe("private post");
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it("filters by featured", async () => {
|
|
458
|
+
await postService.create({
|
|
459
|
+
format: "note",
|
|
460
|
+
bodyMarkdown: "featured post",
|
|
461
|
+
featured: true,
|
|
462
|
+
});
|
|
463
|
+
await postService.create({
|
|
464
|
+
format: "note",
|
|
465
|
+
bodyMarkdown: "normal post",
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
const featured = await postService.list({ featured: true });
|
|
469
|
+
expect(featured).toHaveLength(1);
|
|
470
|
+
expect(featured[0]?.featuredAt).toBeTypeOf("number");
|
|
471
|
+
expect(featured[0]?.bodyText).toBe("featured post");
|
|
472
|
+
|
|
473
|
+
const notFeatured = await postService.list({ featured: false });
|
|
474
|
+
expect(notFeatured).toHaveLength(1);
|
|
475
|
+
expect(notFeatured[0]?.featuredAt).toBeNull();
|
|
476
|
+
expect(notFeatured[0]?.bodyText).toBe("normal post");
|
|
340
477
|
});
|
|
341
478
|
|
|
342
479
|
it("excludes unlisted posts when requested", async () => {
|
|
343
480
|
await postService.create({
|
|
344
481
|
format: "note",
|
|
345
|
-
|
|
482
|
+
bodyMarkdown: "public post",
|
|
346
483
|
});
|
|
347
484
|
await postService.create({
|
|
348
485
|
format: "note",
|
|
349
|
-
|
|
486
|
+
bodyMarkdown: "unlisted post",
|
|
350
487
|
visibility: "unlisted",
|
|
351
488
|
});
|
|
352
489
|
await postService.create({
|
|
353
490
|
format: "note",
|
|
354
|
-
|
|
355
|
-
|
|
491
|
+
bodyMarkdown: "featured post",
|
|
492
|
+
featured: true,
|
|
356
493
|
});
|
|
357
494
|
|
|
358
495
|
const posts = await postService.list({ excludeUnlisted: true });
|
|
359
496
|
expect(posts).toHaveLength(2);
|
|
360
|
-
|
|
497
|
+
// Featured posts have visibility "public", so both public and featured appear
|
|
498
|
+
expect(posts.map((p) => p.bodyText).sort()).toEqual([
|
|
499
|
+
"featured post",
|
|
500
|
+
"public post",
|
|
501
|
+
]);
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
it("excludes private posts when excludePrivate is set", async () => {
|
|
505
|
+
await postService.create({
|
|
506
|
+
format: "note",
|
|
507
|
+
bodyMarkdown: "public post",
|
|
508
|
+
});
|
|
509
|
+
await postService.create({
|
|
510
|
+
format: "note",
|
|
511
|
+
bodyMarkdown: "private post",
|
|
512
|
+
visibility: "private",
|
|
513
|
+
});
|
|
514
|
+
await postService.create({
|
|
515
|
+
format: "note",
|
|
516
|
+
bodyMarkdown: "featured post",
|
|
517
|
+
featured: true,
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
const posts = await postService.list({ excludePrivate: true });
|
|
521
|
+
expect(posts).toHaveLength(2);
|
|
522
|
+
// Featured posts have visibility "public", so both public and featured appear
|
|
523
|
+
expect(posts.map((p) => p.bodyText).sort()).toEqual([
|
|
361
524
|
"featured post",
|
|
362
|
-
"
|
|
525
|
+
"public post",
|
|
363
526
|
]);
|
|
364
527
|
});
|
|
365
528
|
|
|
366
529
|
it("filters by pinned", async () => {
|
|
367
530
|
await postService.create({
|
|
368
531
|
format: "note",
|
|
369
|
-
|
|
532
|
+
bodyMarkdown: "pinned post",
|
|
370
533
|
pinned: true,
|
|
371
534
|
});
|
|
372
535
|
await postService.create({
|
|
373
536
|
format: "note",
|
|
374
|
-
|
|
537
|
+
bodyMarkdown: "normal post",
|
|
375
538
|
});
|
|
376
539
|
|
|
377
540
|
const pinned = await postService.list({ pinned: true });
|
|
378
541
|
expect(pinned).toHaveLength(1);
|
|
379
|
-
expect(pinned[0]?.
|
|
380
|
-
expect(pinned[0]?.
|
|
542
|
+
expect(pinned[0]?.pinnedAt).toBeTypeOf("number");
|
|
543
|
+
expect(pinned[0]?.bodyText).toBe("pinned post");
|
|
381
544
|
|
|
382
545
|
const notPinned = await postService.list({ pinned: false });
|
|
383
546
|
expect(notPinned).toHaveLength(1);
|
|
384
|
-
expect(notPinned[0]?.
|
|
385
|
-
expect(notPinned[0]?.
|
|
547
|
+
expect(notPinned[0]?.pinnedAt).toBeNull();
|
|
548
|
+
expect(notPinned[0]?.bodyText).toBe("normal post");
|
|
386
549
|
});
|
|
387
550
|
|
|
388
551
|
it("excludes deleted posts by default", async () => {
|
|
389
552
|
const post = await postService.create({
|
|
390
553
|
format: "note",
|
|
391
|
-
|
|
554
|
+
bodyMarkdown: "test",
|
|
392
555
|
});
|
|
393
|
-
await postService.create({ format: "note",
|
|
556
|
+
await postService.create({ format: "note", bodyMarkdown: "kept" });
|
|
394
557
|
await postService.delete(post.id);
|
|
395
558
|
|
|
396
559
|
const posts = await postService.list();
|
|
397
560
|
expect(posts).toHaveLength(1);
|
|
398
|
-
expect(posts[0]?.
|
|
561
|
+
expect(posts[0]?.bodyText).toBe("kept");
|
|
399
562
|
});
|
|
400
563
|
|
|
401
564
|
it("includes deleted posts when requested", async () => {
|
|
402
565
|
const post = await postService.create({
|
|
403
566
|
format: "note",
|
|
404
|
-
|
|
567
|
+
bodyMarkdown: "test",
|
|
405
568
|
});
|
|
406
569
|
await postService.delete(post.id);
|
|
407
570
|
|
|
@@ -411,7 +574,7 @@ describe("PostService", () => {
|
|
|
411
574
|
|
|
412
575
|
it("supports limit", async () => {
|
|
413
576
|
for (let i = 0; i < 5; i++) {
|
|
414
|
-
await postService.create({ format: "note",
|
|
577
|
+
await postService.create({ format: "note", bodyMarkdown: `post ${i}` });
|
|
415
578
|
}
|
|
416
579
|
|
|
417
580
|
const posts = await postService.list({ limit: 2 });
|
|
@@ -424,7 +587,7 @@ describe("PostService", () => {
|
|
|
424
587
|
created.push(
|
|
425
588
|
await postService.create({
|
|
426
589
|
format: "note",
|
|
427
|
-
|
|
590
|
+
bodyMarkdown: `post ${i}`,
|
|
428
591
|
publishedAt: 1000 + i,
|
|
429
592
|
}),
|
|
430
593
|
);
|
|
@@ -439,24 +602,24 @@ describe("PostService", () => {
|
|
|
439
602
|
it("excludes replies when requested", async () => {
|
|
440
603
|
const root = await postService.create({
|
|
441
604
|
format: "note",
|
|
442
|
-
|
|
605
|
+
bodyMarkdown: "root post",
|
|
443
606
|
});
|
|
444
607
|
await postService.create({
|
|
445
608
|
format: "note",
|
|
446
|
-
|
|
609
|
+
bodyMarkdown: "reply",
|
|
447
610
|
replyToId: root.id,
|
|
448
611
|
});
|
|
449
612
|
|
|
450
613
|
const posts = await postService.list({ excludeReplies: true });
|
|
451
614
|
expect(posts).toHaveLength(1);
|
|
452
|
-
expect(posts[0]?.
|
|
615
|
+
expect(posts[0]?.bodyText).toBe("root post");
|
|
453
616
|
});
|
|
454
617
|
|
|
455
618
|
it("supports offset pagination", async () => {
|
|
456
619
|
for (let i = 0; i < 5; i++) {
|
|
457
620
|
await postService.create({
|
|
458
621
|
format: "note",
|
|
459
|
-
|
|
622
|
+
bodyMarkdown: `post ${i}`,
|
|
460
623
|
publishedAt: 1000 + i,
|
|
461
624
|
});
|
|
462
625
|
}
|
|
@@ -464,8 +627,8 @@ describe("PostService", () => {
|
|
|
464
627
|
// Skip the first 2 posts (newest), get 2 more
|
|
465
628
|
const posts = await postService.list({ limit: 2, offset: 2 });
|
|
466
629
|
expect(posts).toHaveLength(2);
|
|
467
|
-
expect(posts[0]?.
|
|
468
|
-
expect(posts[1]?.
|
|
630
|
+
expect(posts[0]?.bodyText).toBe("post 2");
|
|
631
|
+
expect(posts[1]?.bodyText).toBe("post 1");
|
|
469
632
|
});
|
|
470
633
|
});
|
|
471
634
|
|
|
@@ -476,9 +639,9 @@ describe("PostService", () => {
|
|
|
476
639
|
});
|
|
477
640
|
|
|
478
641
|
it("counts all non-deleted posts", async () => {
|
|
479
|
-
await postService.create({ format: "note",
|
|
480
|
-
await postService.create({ format: "note",
|
|
481
|
-
await postService.create({ format: "note",
|
|
642
|
+
await postService.create({ format: "note", bodyMarkdown: "first" });
|
|
643
|
+
await postService.create({ format: "note", bodyMarkdown: "second" });
|
|
644
|
+
await postService.create({ format: "note", bodyMarkdown: "third" });
|
|
482
645
|
|
|
483
646
|
const count = await postService.count();
|
|
484
647
|
expect(count).toBe(3);
|
|
@@ -487,12 +650,12 @@ describe("PostService", () => {
|
|
|
487
650
|
it("filters by status", async () => {
|
|
488
651
|
await postService.create({
|
|
489
652
|
format: "note",
|
|
490
|
-
|
|
653
|
+
bodyMarkdown: "published",
|
|
491
654
|
status: "published",
|
|
492
655
|
});
|
|
493
656
|
await postService.create({
|
|
494
657
|
format: "note",
|
|
495
|
-
|
|
658
|
+
bodyMarkdown: "draft",
|
|
496
659
|
status: "draft",
|
|
497
660
|
});
|
|
498
661
|
|
|
@@ -503,21 +666,36 @@ describe("PostService", () => {
|
|
|
503
666
|
it("filters by visibility", async () => {
|
|
504
667
|
await postService.create({
|
|
505
668
|
format: "note",
|
|
506
|
-
|
|
507
|
-
visibility: "
|
|
669
|
+
bodyMarkdown: "unlisted",
|
|
670
|
+
visibility: "unlisted",
|
|
508
671
|
});
|
|
509
|
-
await postService.create({ format: "note",
|
|
672
|
+
await postService.create({ format: "note", bodyMarkdown: "normal" });
|
|
510
673
|
|
|
511
|
-
const count = await postService.count({ visibility: "
|
|
674
|
+
const count = await postService.count({ visibility: "unlisted" });
|
|
512
675
|
expect(count).toBe(1);
|
|
513
676
|
});
|
|
514
677
|
|
|
678
|
+
it("filters by featured", async () => {
|
|
679
|
+
await postService.create({
|
|
680
|
+
format: "note",
|
|
681
|
+
bodyMarkdown: "featured",
|
|
682
|
+
featured: true,
|
|
683
|
+
});
|
|
684
|
+
await postService.create({ format: "note", bodyMarkdown: "normal" });
|
|
685
|
+
|
|
686
|
+
const featuredCount = await postService.count({ featured: true });
|
|
687
|
+
expect(featuredCount).toBe(1);
|
|
688
|
+
|
|
689
|
+
const notFeaturedCount = await postService.count({ featured: false });
|
|
690
|
+
expect(notFeaturedCount).toBe(1);
|
|
691
|
+
});
|
|
692
|
+
|
|
515
693
|
it("excludes deleted posts by default", async () => {
|
|
516
694
|
const post = await postService.create({
|
|
517
695
|
format: "note",
|
|
518
|
-
|
|
696
|
+
bodyMarkdown: "to delete",
|
|
519
697
|
});
|
|
520
|
-
await postService.create({ format: "note",
|
|
698
|
+
await postService.create({ format: "note", bodyMarkdown: "keep" });
|
|
521
699
|
await postService.delete(post.id);
|
|
522
700
|
|
|
523
701
|
const count = await postService.count();
|
|
@@ -527,11 +705,11 @@ describe("PostService", () => {
|
|
|
527
705
|
it("excludes replies when requested", async () => {
|
|
528
706
|
const root = await postService.create({
|
|
529
707
|
format: "note",
|
|
530
|
-
|
|
708
|
+
bodyMarkdown: "root",
|
|
531
709
|
});
|
|
532
710
|
await postService.create({
|
|
533
711
|
format: "note",
|
|
534
|
-
|
|
712
|
+
bodyMarkdown: "reply",
|
|
535
713
|
replyToId: root.id,
|
|
536
714
|
});
|
|
537
715
|
|
|
@@ -576,7 +754,7 @@ describe("PostService", () => {
|
|
|
576
754
|
it("updates post title", async () => {
|
|
577
755
|
const post = await postService.create({
|
|
578
756
|
format: "link",
|
|
579
|
-
|
|
757
|
+
bodyMarkdown: "body",
|
|
580
758
|
title: "Original Title",
|
|
581
759
|
url: "https://example.com",
|
|
582
760
|
});
|
|
@@ -591,7 +769,7 @@ describe("PostService", () => {
|
|
|
591
769
|
it("updates post url", async () => {
|
|
592
770
|
const post = await postService.create({
|
|
593
771
|
format: "link",
|
|
594
|
-
|
|
772
|
+
bodyMarkdown: "link post",
|
|
595
773
|
url: "https://old.com",
|
|
596
774
|
});
|
|
597
775
|
|
|
@@ -602,29 +780,29 @@ describe("PostService", () => {
|
|
|
602
780
|
expect(updated?.url).toBe("https://new-source.com/path");
|
|
603
781
|
});
|
|
604
782
|
|
|
605
|
-
it("
|
|
783
|
+
it("rejects clearing url from a link post", async () => {
|
|
606
784
|
const post = await postService.create({
|
|
607
785
|
format: "link",
|
|
608
|
-
|
|
786
|
+
bodyMarkdown: "test",
|
|
609
787
|
url: "https://example.com",
|
|
610
788
|
});
|
|
611
789
|
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
790
|
+
await expect(
|
|
791
|
+
postService.update(post.id, {
|
|
792
|
+
url: null,
|
|
793
|
+
}),
|
|
794
|
+
).rejects.toThrow("Link posts need a URL.");
|
|
617
795
|
});
|
|
618
796
|
|
|
619
797
|
it("returns null for non-existent post", async () => {
|
|
620
|
-
const result = await postService.update(9999, {
|
|
798
|
+
const result = await postService.update(9999, { bodyMarkdown: "test" });
|
|
621
799
|
expect(result).toBeNull();
|
|
622
800
|
});
|
|
623
801
|
|
|
624
802
|
it("updates updatedAt timestamp", async () => {
|
|
625
803
|
const post = await postService.create({
|
|
626
804
|
format: "note",
|
|
627
|
-
|
|
805
|
+
bodyMarkdown: "test",
|
|
628
806
|
});
|
|
629
807
|
const originalUpdatedAt = post.updatedAt;
|
|
630
808
|
|
|
@@ -632,54 +810,124 @@ describe("PostService", () => {
|
|
|
632
810
|
await new Promise((r) => setTimeout(r, 1100));
|
|
633
811
|
|
|
634
812
|
const updated = await postService.update(post.id, {
|
|
635
|
-
|
|
813
|
+
bodyMarkdown: "modified",
|
|
636
814
|
});
|
|
637
815
|
|
|
638
816
|
expect(updated?.updatedAt).toBeGreaterThanOrEqual(originalUpdatedAt);
|
|
639
817
|
});
|
|
640
818
|
|
|
819
|
+
it("sets publishedAt when publishing a draft", async () => {
|
|
820
|
+
const post = await postService.create({
|
|
821
|
+
format: "note",
|
|
822
|
+
bodyMarkdown: "draft",
|
|
823
|
+
status: "draft",
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
expect(post.publishedAt).toBeNull();
|
|
827
|
+
|
|
828
|
+
await new Promise((r) => setTimeout(r, 1100));
|
|
829
|
+
|
|
830
|
+
const published = await postService.update(post.id, {
|
|
831
|
+
status: "published",
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
expect(published?.status).toBe("published");
|
|
835
|
+
expect(published?.publishedAt).toBeTypeOf("number");
|
|
836
|
+
expect((published?.publishedAt ?? 0) >= post.updatedAt).toBe(true);
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
it("clears publishedAt when converting a published post back to draft", async () => {
|
|
840
|
+
const post = await postService.create({
|
|
841
|
+
format: "note",
|
|
842
|
+
bodyMarkdown: "published",
|
|
843
|
+
publishedAt: 1706745600,
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
const draft = await postService.update(post.id, {
|
|
847
|
+
status: "draft",
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
expect(draft?.status).toBe("draft");
|
|
851
|
+
expect(draft?.publishedAt).toBeNull();
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
it("rejects setting publishedAt while remaining a draft", async () => {
|
|
855
|
+
const post = await postService.create({
|
|
856
|
+
format: "note",
|
|
857
|
+
bodyMarkdown: "draft",
|
|
858
|
+
status: "draft",
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
await expect(
|
|
862
|
+
postService.update(post.id, {
|
|
863
|
+
publishedAt: 1706745600,
|
|
864
|
+
}),
|
|
865
|
+
).rejects.toThrow("Drafts can't set a publish time.");
|
|
866
|
+
});
|
|
867
|
+
|
|
641
868
|
it("updates visibility", async () => {
|
|
642
869
|
const post = await postService.create({
|
|
643
870
|
format: "note",
|
|
644
|
-
|
|
871
|
+
bodyMarkdown: "test",
|
|
645
872
|
});
|
|
646
873
|
|
|
647
|
-
expect(post.visibility).toBe("
|
|
874
|
+
expect(post.visibility).toBe("public");
|
|
648
875
|
|
|
649
876
|
const updated = await postService.update(post.id, {
|
|
650
|
-
visibility: "
|
|
877
|
+
visibility: "unlisted",
|
|
651
878
|
});
|
|
652
879
|
|
|
653
|
-
expect(updated?.visibility).toBe("
|
|
880
|
+
expect(updated?.visibility).toBe("unlisted");
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
it("updates featured flag", async () => {
|
|
884
|
+
const post = await postService.create({
|
|
885
|
+
format: "note",
|
|
886
|
+
bodyMarkdown: "test",
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
expect(post.featuredAt).toBeNull();
|
|
890
|
+
|
|
891
|
+
const featured = await postService.update(post.id, {
|
|
892
|
+
featured: true,
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
expect(featured?.featuredAt).toBeTypeOf("number");
|
|
896
|
+
|
|
897
|
+
const unfeatured = await postService.update(post.id, {
|
|
898
|
+
featured: false,
|
|
899
|
+
});
|
|
900
|
+
|
|
901
|
+
expect(unfeatured?.featuredAt).toBeNull();
|
|
654
902
|
});
|
|
655
903
|
|
|
656
904
|
it("updates pinned flag", async () => {
|
|
657
905
|
const post = await postService.create({
|
|
658
906
|
format: "note",
|
|
659
|
-
|
|
907
|
+
bodyMarkdown: "test",
|
|
660
908
|
});
|
|
661
909
|
|
|
662
|
-
expect(post.
|
|
910
|
+
expect(post.pinnedAt).toBeNull();
|
|
663
911
|
|
|
664
912
|
const updated = await postService.update(post.id, {
|
|
665
913
|
pinned: true,
|
|
666
914
|
});
|
|
667
915
|
|
|
668
|
-
expect(updated?.
|
|
916
|
+
expect(updated?.pinnedAt).toBeTypeOf("number");
|
|
669
917
|
});
|
|
670
918
|
|
|
671
|
-
it("updates
|
|
919
|
+
it("updates slug", async () => {
|
|
672
920
|
const post = await postService.create({
|
|
673
921
|
format: "note",
|
|
674
|
-
|
|
675
|
-
|
|
922
|
+
bodyMarkdown: "test",
|
|
923
|
+
slug: "old-slug",
|
|
676
924
|
});
|
|
677
925
|
|
|
678
926
|
const updated = await postService.update(post.id, {
|
|
679
|
-
|
|
927
|
+
slug: "new-slug",
|
|
680
928
|
});
|
|
681
929
|
|
|
682
|
-
expect(updated?.
|
|
930
|
+
expect(updated?.slug).toBe("new-slug");
|
|
683
931
|
});
|
|
684
932
|
|
|
685
933
|
it("updates quoteText and rating", async () => {
|
|
@@ -697,13 +945,40 @@ describe("PostService", () => {
|
|
|
697
945
|
expect(updated?.quoteText).toBe("Updated quote");
|
|
698
946
|
expect(updated?.rating).toBe(5);
|
|
699
947
|
});
|
|
948
|
+
|
|
949
|
+
it("rejects switching a note to link without adding a URL", async () => {
|
|
950
|
+
const post = await postService.create({
|
|
951
|
+
format: "note",
|
|
952
|
+
bodyMarkdown: "test",
|
|
953
|
+
});
|
|
954
|
+
|
|
955
|
+
await expect(
|
|
956
|
+
postService.update(post.id, {
|
|
957
|
+
format: "link",
|
|
958
|
+
}),
|
|
959
|
+
).rejects.toThrow("Link posts need a URL.");
|
|
960
|
+
});
|
|
961
|
+
|
|
962
|
+
it("rejects switching a link to note without clearing the URL", async () => {
|
|
963
|
+
const post = await postService.create({
|
|
964
|
+
format: "link",
|
|
965
|
+
bodyMarkdown: "test",
|
|
966
|
+
url: "https://example.com",
|
|
967
|
+
});
|
|
968
|
+
|
|
969
|
+
await expect(
|
|
970
|
+
postService.update(post.id, {
|
|
971
|
+
format: "note",
|
|
972
|
+
}),
|
|
973
|
+
).rejects.toThrow("Notes can't include a URL.");
|
|
974
|
+
});
|
|
700
975
|
});
|
|
701
976
|
|
|
702
977
|
describe("delete (soft delete)", () => {
|
|
703
978
|
it("soft-deletes a post", async () => {
|
|
704
979
|
const post = await postService.create({
|
|
705
980
|
format: "note",
|
|
706
|
-
|
|
981
|
+
bodyMarkdown: "test",
|
|
707
982
|
});
|
|
708
983
|
|
|
709
984
|
const result = await postService.delete(post.id);
|
|
@@ -722,11 +997,11 @@ describe("PostService", () => {
|
|
|
722
997
|
it("cascade deletes thread when deleting root post", async () => {
|
|
723
998
|
const root = await postService.create({
|
|
724
999
|
format: "note",
|
|
725
|
-
|
|
1000
|
+
bodyMarkdown: "root",
|
|
726
1001
|
});
|
|
727
1002
|
const reply = await postService.create({
|
|
728
1003
|
format: "note",
|
|
729
|
-
|
|
1004
|
+
bodyMarkdown: "reply",
|
|
730
1005
|
replyToId: root.id,
|
|
731
1006
|
});
|
|
732
1007
|
|
|
@@ -740,16 +1015,16 @@ describe("PostService", () => {
|
|
|
740
1015
|
it("only deletes single post when deleting a reply", async () => {
|
|
741
1016
|
const root = await postService.create({
|
|
742
1017
|
format: "note",
|
|
743
|
-
|
|
1018
|
+
bodyMarkdown: "root",
|
|
744
1019
|
});
|
|
745
1020
|
const reply1 = await postService.create({
|
|
746
1021
|
format: "note",
|
|
747
|
-
|
|
1022
|
+
bodyMarkdown: "reply1",
|
|
748
1023
|
replyToId: root.id,
|
|
749
1024
|
});
|
|
750
1025
|
await postService.create({
|
|
751
1026
|
format: "note",
|
|
752
|
-
|
|
1027
|
+
bodyMarkdown: "reply2",
|
|
753
1028
|
replyToId: root.id,
|
|
754
1029
|
});
|
|
755
1030
|
|
|
@@ -766,11 +1041,11 @@ describe("PostService", () => {
|
|
|
766
1041
|
it("sets threadId on reply to a root post", async () => {
|
|
767
1042
|
const root = await postService.create({
|
|
768
1043
|
format: "note",
|
|
769
|
-
|
|
1044
|
+
bodyMarkdown: "root",
|
|
770
1045
|
});
|
|
771
1046
|
const reply = await postService.create({
|
|
772
1047
|
format: "note",
|
|
773
|
-
|
|
1048
|
+
bodyMarkdown: "reply",
|
|
774
1049
|
replyToId: root.id,
|
|
775
1050
|
});
|
|
776
1051
|
|
|
@@ -781,16 +1056,16 @@ describe("PostService", () => {
|
|
|
781
1056
|
it("inherits threadId from parent in nested replies", async () => {
|
|
782
1057
|
const root = await postService.create({
|
|
783
1058
|
format: "note",
|
|
784
|
-
|
|
1059
|
+
bodyMarkdown: "root",
|
|
785
1060
|
});
|
|
786
1061
|
const reply1 = await postService.create({
|
|
787
1062
|
format: "note",
|
|
788
|
-
|
|
1063
|
+
bodyMarkdown: "reply1",
|
|
789
1064
|
replyToId: root.id,
|
|
790
1065
|
});
|
|
791
1066
|
const reply2 = await postService.create({
|
|
792
1067
|
format: "note",
|
|
793
|
-
|
|
1068
|
+
bodyMarkdown: "reply2",
|
|
794
1069
|
replyToId: reply1.id,
|
|
795
1070
|
});
|
|
796
1071
|
|
|
@@ -802,63 +1077,121 @@ describe("PostService", () => {
|
|
|
802
1077
|
it("inherits status from root post", async () => {
|
|
803
1078
|
const root = await postService.create({
|
|
804
1079
|
format: "note",
|
|
805
|
-
|
|
1080
|
+
bodyMarkdown: "root",
|
|
806
1081
|
status: "draft",
|
|
807
1082
|
});
|
|
808
1083
|
const reply = await postService.create({
|
|
809
1084
|
format: "note",
|
|
810
|
-
|
|
1085
|
+
bodyMarkdown: "reply",
|
|
811
1086
|
replyToId: root.id,
|
|
812
1087
|
});
|
|
813
1088
|
|
|
814
1089
|
expect(reply.status).toBe("draft");
|
|
815
1090
|
});
|
|
816
1091
|
|
|
1092
|
+
it("preserves draft status when reply explicitly requests it", async () => {
|
|
1093
|
+
const root = await postService.create({
|
|
1094
|
+
format: "note",
|
|
1095
|
+
bodyMarkdown: "root",
|
|
1096
|
+
status: "published",
|
|
1097
|
+
});
|
|
1098
|
+
const reply = await postService.create({
|
|
1099
|
+
format: "note",
|
|
1100
|
+
bodyMarkdown: "reply",
|
|
1101
|
+
status: "draft",
|
|
1102
|
+
replyToId: root.id,
|
|
1103
|
+
});
|
|
1104
|
+
|
|
1105
|
+
expect(reply.status).toBe("draft");
|
|
1106
|
+
expect(reply.threadId).toBe(root.id);
|
|
1107
|
+
});
|
|
1108
|
+
|
|
817
1109
|
it("inherits visibility from root post", async () => {
|
|
818
1110
|
const root = await postService.create({
|
|
819
1111
|
format: "note",
|
|
820
|
-
|
|
821
|
-
visibility: "
|
|
1112
|
+
bodyMarkdown: "root",
|
|
1113
|
+
visibility: "unlisted",
|
|
1114
|
+
});
|
|
1115
|
+
const reply = await postService.create({
|
|
1116
|
+
format: "note",
|
|
1117
|
+
bodyMarkdown: "reply",
|
|
1118
|
+
replyToId: root.id,
|
|
1119
|
+
});
|
|
1120
|
+
|
|
1121
|
+
expect(reply.visibility).toBe("unlisted");
|
|
1122
|
+
});
|
|
1123
|
+
|
|
1124
|
+
it("stores reply visibility as null and resolves it from the root", async () => {
|
|
1125
|
+
const root = await postService.create({
|
|
1126
|
+
format: "note",
|
|
1127
|
+
bodyMarkdown: "root",
|
|
1128
|
+
visibility: "private",
|
|
822
1129
|
});
|
|
823
1130
|
const reply = await postService.create({
|
|
824
1131
|
format: "note",
|
|
825
|
-
|
|
1132
|
+
bodyMarkdown: "reply",
|
|
826
1133
|
replyToId: root.id,
|
|
827
1134
|
});
|
|
828
1135
|
|
|
829
|
-
|
|
1136
|
+
const rows = await db
|
|
1137
|
+
.select({ visibility: posts.visibility })
|
|
1138
|
+
.from(posts)
|
|
1139
|
+
.where(eq(posts.id, reply.id))
|
|
1140
|
+
.limit(1);
|
|
1141
|
+
|
|
1142
|
+
expect(rows[0]?.visibility).toBeNull();
|
|
1143
|
+
expect(reply.visibility).toBe("private");
|
|
1144
|
+
});
|
|
1145
|
+
|
|
1146
|
+
it("does not inherit featuredAt from root post", async () => {
|
|
1147
|
+
const root = await postService.create({
|
|
1148
|
+
format: "note",
|
|
1149
|
+
bodyMarkdown: "root",
|
|
1150
|
+
featured: true,
|
|
1151
|
+
});
|
|
1152
|
+
|
|
1153
|
+
expect(root.featuredAt).toBeTypeOf("number");
|
|
1154
|
+
|
|
1155
|
+
const reply = await postService.create({
|
|
1156
|
+
format: "note",
|
|
1157
|
+
bodyMarkdown: "reply",
|
|
1158
|
+
replyToId: root.id,
|
|
1159
|
+
});
|
|
1160
|
+
|
|
1161
|
+
// featuredAt is an independent property — replies should NOT inherit it
|
|
1162
|
+
expect(reply.featuredAt).toBeNull();
|
|
830
1163
|
});
|
|
831
1164
|
|
|
832
1165
|
it("getThread returns all posts in a thread", async () => {
|
|
833
1166
|
const root = await postService.create({
|
|
834
1167
|
format: "note",
|
|
835
|
-
|
|
1168
|
+
bodyMarkdown: "root",
|
|
836
1169
|
});
|
|
837
1170
|
await postService.create({
|
|
838
1171
|
format: "note",
|
|
839
|
-
|
|
1172
|
+
bodyMarkdown: "reply1",
|
|
840
1173
|
replyToId: root.id,
|
|
841
1174
|
});
|
|
842
1175
|
await postService.create({
|
|
843
1176
|
format: "note",
|
|
844
|
-
|
|
1177
|
+
bodyMarkdown: "reply2",
|
|
845
1178
|
replyToId: root.id,
|
|
846
1179
|
});
|
|
847
1180
|
|
|
848
1181
|
const thread = await postService.getThread(root.id);
|
|
849
1182
|
expect(thread).toHaveLength(3);
|
|
850
1183
|
// Ordered by createdAt
|
|
851
|
-
expect(thread[0]?.
|
|
1184
|
+
expect(thread[0]?.bodyText).toBe("root");
|
|
852
1185
|
});
|
|
853
1186
|
|
|
854
1187
|
it("getThread excludes deleted posts", async () => {
|
|
855
1188
|
const root = await postService.create({
|
|
856
1189
|
format: "note",
|
|
857
|
-
|
|
1190
|
+
bodyMarkdown: "root",
|
|
858
1191
|
});
|
|
859
1192
|
const reply = await postService.create({
|
|
860
1193
|
format: "note",
|
|
861
|
-
|
|
1194
|
+
bodyMarkdown: "reply",
|
|
862
1195
|
replyToId: root.id,
|
|
863
1196
|
});
|
|
864
1197
|
|
|
@@ -871,12 +1204,12 @@ describe("PostService", () => {
|
|
|
871
1204
|
it("cascades status changes from root to thread", async () => {
|
|
872
1205
|
const root = await postService.create({
|
|
873
1206
|
format: "note",
|
|
874
|
-
|
|
1207
|
+
bodyMarkdown: "root",
|
|
875
1208
|
status: "published",
|
|
876
1209
|
});
|
|
877
1210
|
await postService.create({
|
|
878
1211
|
format: "note",
|
|
879
|
-
|
|
1212
|
+
bodyMarkdown: "reply",
|
|
880
1213
|
replyToId: root.id,
|
|
881
1214
|
});
|
|
882
1215
|
|
|
@@ -885,27 +1218,151 @@ describe("PostService", () => {
|
|
|
885
1218
|
const thread = await postService.getThread(root.id);
|
|
886
1219
|
for (const post of thread) {
|
|
887
1220
|
expect(post.status).toBe("draft");
|
|
1221
|
+
expect(post.publishedAt).toBeNull();
|
|
1222
|
+
}
|
|
1223
|
+
});
|
|
1224
|
+
|
|
1225
|
+
it("publishing a draft thread stamps publishedAt on all posts", async () => {
|
|
1226
|
+
const root = await postService.create({
|
|
1227
|
+
format: "note",
|
|
1228
|
+
bodyMarkdown: "root",
|
|
1229
|
+
status: "draft",
|
|
1230
|
+
});
|
|
1231
|
+
await postService.create({
|
|
1232
|
+
format: "note",
|
|
1233
|
+
bodyMarkdown: "reply",
|
|
1234
|
+
replyToId: root.id,
|
|
1235
|
+
});
|
|
1236
|
+
|
|
1237
|
+
await new Promise((r) => setTimeout(r, 1100));
|
|
1238
|
+
await postService.update(root.id, { status: "published" });
|
|
1239
|
+
|
|
1240
|
+
const thread = await postService.getThread(root.id);
|
|
1241
|
+
for (const post of thread) {
|
|
1242
|
+
expect(post.status).toBe("published");
|
|
1243
|
+
expect(post.publishedAt).toBeTypeOf("number");
|
|
888
1244
|
}
|
|
889
1245
|
});
|
|
890
1246
|
|
|
891
1247
|
it("cascades visibility changes from root to thread", async () => {
|
|
892
1248
|
const root = await postService.create({
|
|
893
1249
|
format: "note",
|
|
894
|
-
|
|
1250
|
+
bodyMarkdown: "root",
|
|
895
1251
|
});
|
|
896
1252
|
await postService.create({
|
|
897
1253
|
format: "note",
|
|
898
|
-
|
|
1254
|
+
bodyMarkdown: "reply",
|
|
899
1255
|
replyToId: root.id,
|
|
900
1256
|
});
|
|
901
1257
|
|
|
902
|
-
await postService.update(root.id, { visibility: "
|
|
1258
|
+
await postService.update(root.id, { visibility: "unlisted" });
|
|
903
1259
|
|
|
904
1260
|
const thread = await postService.getThread(root.id);
|
|
905
1261
|
for (const post of thread) {
|
|
906
|
-
expect(post.visibility).toBe("
|
|
1262
|
+
expect(post.visibility).toBe("unlisted");
|
|
907
1263
|
}
|
|
908
1264
|
});
|
|
1265
|
+
|
|
1266
|
+
it("filters replies by the root post visibility", async () => {
|
|
1267
|
+
const unlistedRoot = await postService.create({
|
|
1268
|
+
format: "note",
|
|
1269
|
+
bodyMarkdown: "root",
|
|
1270
|
+
visibility: "unlisted",
|
|
1271
|
+
});
|
|
1272
|
+
const unlistedReply = await postService.create({
|
|
1273
|
+
format: "note",
|
|
1274
|
+
bodyMarkdown: "reply",
|
|
1275
|
+
replyToId: unlistedRoot.id,
|
|
1276
|
+
});
|
|
1277
|
+
await postService.create({
|
|
1278
|
+
format: "note",
|
|
1279
|
+
bodyMarkdown: "public root",
|
|
1280
|
+
});
|
|
1281
|
+
|
|
1282
|
+
const postsByVisibility = await postService.list({
|
|
1283
|
+
visibility: "unlisted",
|
|
1284
|
+
});
|
|
1285
|
+
|
|
1286
|
+
expect(postsByVisibility.map((post) => post.id)).toEqual([
|
|
1287
|
+
unlistedReply.id,
|
|
1288
|
+
unlistedRoot.id,
|
|
1289
|
+
]);
|
|
1290
|
+
});
|
|
1291
|
+
|
|
1292
|
+
it("rejects visibility changes on thread replies", async () => {
|
|
1293
|
+
const root = await postService.create({
|
|
1294
|
+
format: "note",
|
|
1295
|
+
bodyMarkdown: "root",
|
|
1296
|
+
});
|
|
1297
|
+
const reply = await postService.create({
|
|
1298
|
+
format: "note",
|
|
1299
|
+
bodyMarkdown: "reply",
|
|
1300
|
+
replyToId: root.id,
|
|
1301
|
+
});
|
|
1302
|
+
|
|
1303
|
+
await expect(
|
|
1304
|
+
postService.update(reply.id, { visibility: "unlisted" }),
|
|
1305
|
+
).rejects.toThrow(
|
|
1306
|
+
"Cannot change visibility of a thread reply. Update the root post instead.",
|
|
1307
|
+
);
|
|
1308
|
+
});
|
|
1309
|
+
|
|
1310
|
+
it("allows featuring a thread reply", async () => {
|
|
1311
|
+
const root = await postService.create({
|
|
1312
|
+
format: "note",
|
|
1313
|
+
bodyMarkdown: "root",
|
|
1314
|
+
});
|
|
1315
|
+
const reply = await postService.create({
|
|
1316
|
+
format: "note",
|
|
1317
|
+
bodyMarkdown: "reply",
|
|
1318
|
+
replyToId: root.id,
|
|
1319
|
+
});
|
|
1320
|
+
|
|
1321
|
+
// Featured is independent of visibility — replies can be featured
|
|
1322
|
+
const updated = await postService.update(reply.id, { featured: true });
|
|
1323
|
+
expect(updated?.featuredAt).toBeTypeOf("number");
|
|
1324
|
+
|
|
1325
|
+
const unfeatured = await postService.update(reply.id, {
|
|
1326
|
+
featured: false,
|
|
1327
|
+
});
|
|
1328
|
+
expect(unfeatured?.featuredAt).toBeNull();
|
|
1329
|
+
});
|
|
1330
|
+
|
|
1331
|
+
it("rejects creating a pinned thread reply", async () => {
|
|
1332
|
+
const root = await postService.create({
|
|
1333
|
+
format: "note",
|
|
1334
|
+
bodyMarkdown: "root",
|
|
1335
|
+
});
|
|
1336
|
+
|
|
1337
|
+
await expect(
|
|
1338
|
+
postService.create({
|
|
1339
|
+
format: "note",
|
|
1340
|
+
bodyMarkdown: "reply",
|
|
1341
|
+
replyToId: root.id,
|
|
1342
|
+
pinned: true,
|
|
1343
|
+
}),
|
|
1344
|
+
).rejects.toThrow(
|
|
1345
|
+
"Cannot pin a thread reply. Pin the root post instead.",
|
|
1346
|
+
);
|
|
1347
|
+
});
|
|
1348
|
+
|
|
1349
|
+
it("rejects pinning a thread reply", async () => {
|
|
1350
|
+
const root = await postService.create({
|
|
1351
|
+
format: "note",
|
|
1352
|
+
bodyMarkdown: "root",
|
|
1353
|
+
});
|
|
1354
|
+
const reply = await postService.create({
|
|
1355
|
+
format: "note",
|
|
1356
|
+
bodyMarkdown: "reply",
|
|
1357
|
+
replyToId: root.id,
|
|
1358
|
+
});
|
|
1359
|
+
|
|
1360
|
+
await expect(
|
|
1361
|
+
postService.update(reply.id, { pinned: true }),
|
|
1362
|
+
).rejects.toThrow(
|
|
1363
|
+
"Cannot pin a thread reply. Pin the root post instead.",
|
|
1364
|
+
);
|
|
1365
|
+
});
|
|
909
1366
|
});
|
|
910
1367
|
|
|
911
1368
|
describe("getReplyCounts", () => {
|
|
@@ -917,16 +1374,16 @@ describe("PostService", () => {
|
|
|
917
1374
|
it("returns reply counts for posts", async () => {
|
|
918
1375
|
const root = await postService.create({
|
|
919
1376
|
format: "note",
|
|
920
|
-
|
|
1377
|
+
bodyMarkdown: "root",
|
|
921
1378
|
});
|
|
922
1379
|
await postService.create({
|
|
923
1380
|
format: "note",
|
|
924
|
-
|
|
1381
|
+
bodyMarkdown: "reply1",
|
|
925
1382
|
replyToId: root.id,
|
|
926
1383
|
});
|
|
927
1384
|
await postService.create({
|
|
928
1385
|
format: "note",
|
|
929
|
-
|
|
1386
|
+
bodyMarkdown: "reply2",
|
|
930
1387
|
replyToId: root.id,
|
|
931
1388
|
});
|
|
932
1389
|
|
|
@@ -937,7 +1394,7 @@ describe("PostService", () => {
|
|
|
937
1394
|
it("returns 0 (missing) for posts without replies", async () => {
|
|
938
1395
|
const post = await postService.create({
|
|
939
1396
|
format: "note",
|
|
940
|
-
|
|
1397
|
+
bodyMarkdown: "no replies",
|
|
941
1398
|
});
|
|
942
1399
|
|
|
943
1400
|
const counts = await postService.getReplyCounts([post.id]);
|
|
@@ -947,16 +1404,16 @@ describe("PostService", () => {
|
|
|
947
1404
|
it("excludes deleted replies from count", async () => {
|
|
948
1405
|
const root = await postService.create({
|
|
949
1406
|
format: "note",
|
|
950
|
-
|
|
1407
|
+
bodyMarkdown: "root",
|
|
951
1408
|
});
|
|
952
1409
|
const reply = await postService.create({
|
|
953
1410
|
format: "note",
|
|
954
|
-
|
|
1411
|
+
bodyMarkdown: "reply",
|
|
955
1412
|
replyToId: root.id,
|
|
956
1413
|
});
|
|
957
1414
|
await postService.create({
|
|
958
1415
|
format: "note",
|
|
959
|
-
|
|
1416
|
+
bodyMarkdown: "reply2",
|
|
960
1417
|
replyToId: root.id,
|
|
961
1418
|
});
|
|
962
1419
|
|
|
@@ -965,100 +1422,122 @@ describe("PostService", () => {
|
|
|
965
1422
|
const counts = await postService.getReplyCounts([root.id]);
|
|
966
1423
|
expect(counts.get(root.id)).toBe(1);
|
|
967
1424
|
});
|
|
968
|
-
});
|
|
969
1425
|
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
const post = await postService.create({
|
|
1426
|
+
it("excludes draft replies from count", async () => {
|
|
1427
|
+
const root = await postService.create({
|
|
973
1428
|
format: "note",
|
|
974
|
-
|
|
975
|
-
path: "my-post",
|
|
1429
|
+
bodyMarkdown: "root",
|
|
976
1430
|
});
|
|
977
|
-
|
|
978
|
-
const entry = await pathRegistry.getByPath("my-post");
|
|
979
|
-
expect(entry).not.toBeNull();
|
|
980
|
-
expect(entry?.ownerType).toBe("post");
|
|
981
|
-
expect(entry?.ownerId).toBe(post.id);
|
|
982
|
-
});
|
|
983
|
-
|
|
984
|
-
it("does not claim when no path provided", async () => {
|
|
985
1431
|
await postService.create({
|
|
986
1432
|
format: "note",
|
|
987
|
-
|
|
1433
|
+
bodyMarkdown: "published reply",
|
|
1434
|
+
replyToId: root.id,
|
|
1435
|
+
});
|
|
1436
|
+
await postService.create({
|
|
1437
|
+
format: "note",
|
|
1438
|
+
bodyMarkdown: "draft reply",
|
|
1439
|
+
replyToId: root.id,
|
|
1440
|
+
status: "draft",
|
|
988
1441
|
});
|
|
989
1442
|
|
|
990
|
-
|
|
991
|
-
expect(
|
|
992
|
-
});
|
|
993
|
-
|
|
994
|
-
it("rejects path that conflicts with a page slug", async () => {
|
|
995
|
-
await pageService.create({ slug: "about", title: "About" });
|
|
996
|
-
|
|
997
|
-
await expect(
|
|
998
|
-
postService.create({ format: "note", body: "test", path: "about" }),
|
|
999
|
-
).rejects.toThrow(ConflictError);
|
|
1000
|
-
});
|
|
1001
|
-
|
|
1002
|
-
it("rejects reserved path on create", async () => {
|
|
1003
|
-
await expect(
|
|
1004
|
-
postService.create({ format: "note", body: "test", path: "dash" }),
|
|
1005
|
-
).rejects.toThrow(ValidationError);
|
|
1443
|
+
const counts = await postService.getReplyCounts([root.id]);
|
|
1444
|
+
expect(counts.get(root.id)).toBe(1);
|
|
1006
1445
|
});
|
|
1446
|
+
});
|
|
1007
1447
|
|
|
1008
|
-
|
|
1448
|
+
describe("lastActivityAt (thread bump-to-top)", () => {
|
|
1449
|
+
it("sets lastActivityAt equal to publishedAt for non-thread posts", async () => {
|
|
1009
1450
|
const post = await postService.create({
|
|
1010
1451
|
format: "note",
|
|
1011
|
-
|
|
1012
|
-
|
|
1452
|
+
bodyMarkdown: "standalone",
|
|
1453
|
+
publishedAt: 5000,
|
|
1013
1454
|
});
|
|
1014
1455
|
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
expect(await pathRegistry.isAvailable("old-path")).toBe(true);
|
|
1018
|
-
expect(await pathRegistry.isAvailable("new-path")).toBe(false);
|
|
1456
|
+
expect(post.lastActivityAt).toBe(5000);
|
|
1019
1457
|
});
|
|
1020
1458
|
|
|
1021
|
-
it("
|
|
1022
|
-
const
|
|
1459
|
+
it("updates root lastActivityAt when a reply is created", async () => {
|
|
1460
|
+
const root = await postService.create({
|
|
1023
1461
|
format: "note",
|
|
1024
|
-
|
|
1025
|
-
|
|
1462
|
+
bodyMarkdown: "root",
|
|
1463
|
+
publishedAt: 1000,
|
|
1026
1464
|
});
|
|
1465
|
+
expect(root.lastActivityAt).toBe(1000);
|
|
1027
1466
|
|
|
1028
|
-
await postService.
|
|
1467
|
+
await postService.create({
|
|
1468
|
+
format: "note",
|
|
1469
|
+
bodyMarkdown: "reply",
|
|
1470
|
+
replyToId: root.id,
|
|
1471
|
+
publishedAt: 9000,
|
|
1472
|
+
});
|
|
1029
1473
|
|
|
1030
|
-
|
|
1474
|
+
const updatedRoot = await postService.getById(root.id);
|
|
1475
|
+
expect(updatedRoot?.lastActivityAt).toBe(9000);
|
|
1031
1476
|
});
|
|
1032
1477
|
|
|
1033
|
-
it("
|
|
1034
|
-
const
|
|
1478
|
+
it("list returns thread root bumped to top after reply", async () => {
|
|
1479
|
+
const oldPost = await postService.create({
|
|
1480
|
+
format: "note",
|
|
1481
|
+
bodyMarkdown: "old thread root",
|
|
1482
|
+
publishedAt: 1000,
|
|
1483
|
+
});
|
|
1484
|
+
await postService.create({
|
|
1035
1485
|
format: "note",
|
|
1036
|
-
|
|
1037
|
-
|
|
1486
|
+
bodyMarkdown: "newer standalone",
|
|
1487
|
+
publishedAt: 5000,
|
|
1038
1488
|
});
|
|
1039
1489
|
|
|
1040
|
-
|
|
1490
|
+
// Reply to old post with a newer timestamp — should bump it above standalone
|
|
1491
|
+
await postService.create({
|
|
1492
|
+
format: "note",
|
|
1493
|
+
bodyMarkdown: "reply",
|
|
1494
|
+
replyToId: oldPost.id,
|
|
1495
|
+
publishedAt: 9000,
|
|
1496
|
+
});
|
|
1041
1497
|
|
|
1042
|
-
|
|
1498
|
+
const listed = await postService.list({ excludeReplies: true });
|
|
1499
|
+
expect(listed[0]?.bodyText).toBe("old thread root");
|
|
1500
|
+
expect(listed[1]?.bodyText).toBe("newer standalone");
|
|
1043
1501
|
});
|
|
1044
1502
|
|
|
1045
|
-
it("
|
|
1503
|
+
it("recalculates root lastActivityAt when a reply is deleted", async () => {
|
|
1046
1504
|
const root = await postService.create({
|
|
1047
1505
|
format: "note",
|
|
1048
|
-
|
|
1049
|
-
|
|
1506
|
+
bodyMarkdown: "root",
|
|
1507
|
+
publishedAt: 1000,
|
|
1508
|
+
});
|
|
1509
|
+
const reply1 = await postService.create({
|
|
1510
|
+
format: "note",
|
|
1511
|
+
bodyMarkdown: "reply1",
|
|
1512
|
+
replyToId: root.id,
|
|
1513
|
+
publishedAt: 3000,
|
|
1050
1514
|
});
|
|
1051
1515
|
await postService.create({
|
|
1052
1516
|
format: "note",
|
|
1053
|
-
|
|
1054
|
-
path: "thread-reply",
|
|
1517
|
+
bodyMarkdown: "reply2",
|
|
1055
1518
|
replyToId: root.id,
|
|
1519
|
+
publishedAt: 5000,
|
|
1056
1520
|
});
|
|
1057
1521
|
|
|
1058
|
-
|
|
1522
|
+
// Root should be bumped to latest reply
|
|
1523
|
+
let updatedRoot = await postService.getById(root.id);
|
|
1524
|
+
expect(updatedRoot?.lastActivityAt).toBe(5000);
|
|
1525
|
+
|
|
1526
|
+
// Delete the latest reply — root should fall back to reply1's time
|
|
1527
|
+
const reply2 = (await postService.list({ threadId: root.id })).find(
|
|
1528
|
+
(p) => p.bodyText === "reply2",
|
|
1529
|
+
);
|
|
1530
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- test setup guarantees reply2 exists
|
|
1531
|
+
await postService.delete(reply2!.id);
|
|
1532
|
+
|
|
1533
|
+
updatedRoot = await postService.getById(root.id);
|
|
1534
|
+
expect(updatedRoot?.lastActivityAt).toBe(3000);
|
|
1535
|
+
|
|
1536
|
+
// Delete the remaining reply — root should fall back to its own publishedAt
|
|
1537
|
+
await postService.delete(reply1.id);
|
|
1059
1538
|
|
|
1060
|
-
|
|
1061
|
-
expect(
|
|
1539
|
+
updatedRoot = await postService.getById(root.id);
|
|
1540
|
+
expect(updatedRoot?.lastActivityAt).toBe(1000);
|
|
1062
1541
|
});
|
|
1063
1542
|
});
|
|
1064
1543
|
});
|