@jant/core 0.3.35 → 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/module-RjUF93sV.js +716 -0
- package/dist/client/assets/native-48B9X9Wg.js +1 -0
- package/dist/client/assets/url-FWFqPJPb.js +1 -0
- package/dist/client/client.css +1 -1
- package/dist/client/client.js +4564 -3013
- package/dist/index.js +12885 -8161
- package/package.json +23 -6
- package/src/__tests__/helpers/app.ts +10 -10
- package/src/__tests__/helpers/db.ts +91 -87
- package/src/app.tsx +157 -31
- 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/{lib → client}/avatar-upload.ts +4 -3
- package/src/{lib → client}/collection-form-bridge.ts +2 -2
- package/src/{ui → client}/components/__tests__/jant-collection-form.test.ts +26 -9
- package/src/client/components/__tests__/jant-compose-dialog.test.ts +1140 -0
- package/src/client/components/__tests__/jant-compose-editor.test.ts +504 -0
- package/src/{ui → client}/components/__tests__/jant-post-form.test.ts +37 -17
- package/src/{ui → client}/components/__tests__/jant-settings-avatar.test.ts +2 -2
- package/src/{ui → client}/components/__tests__/jant-settings-general.test.ts +3 -3
- package/src/client/components/collection-sidebar-types.ts +43 -0
- package/src/{ui → client}/components/collection-types.ts +3 -4
- package/src/client/components/compose-types.ts +174 -0
- package/src/client/components/jant-collection-form.ts +667 -0
- package/src/client/components/jant-collection-sidebar.ts +805 -0
- package/src/client/components/jant-compose-dialog.ts +2161 -0
- package/src/client/components/jant-compose-editor.ts +1813 -0
- package/src/client/components/jant-compose-fullscreen.ts +283 -0
- package/src/client/components/jant-media-lightbox.ts +259 -0
- package/src/{ui → client}/components/jant-nav-manager.ts +97 -298
- package/src/{ui → client}/components/jant-post-form.ts +141 -12
- package/src/client/components/jant-post-menu.ts +1019 -0
- package/src/{ui → client}/components/jant-settings-avatar.ts +3 -3
- package/src/{ui → client}/components/jant-settings-general.ts +38 -4
- package/src/client/components/jant-text-preview.ts +232 -0
- package/src/{ui → client}/components/nav-manager-types.ts +6 -18
- package/src/{ui → client}/components/post-form-template.ts +137 -38
- package/src/{ui → client}/components/post-form-types.ts +15 -4
- package/src/client/compose-bridge.ts +583 -0
- package/src/{lib → client}/image-processor.ts +26 -8
- package/src/client/lazy-slugify.ts +51 -0
- package/src/client/media-metadata.ts +247 -0
- package/src/client/multipart-upload.ts +160 -0
- package/src/{lib → client}/nav-manager-bridge.ts +1 -1
- package/src/{lib → client}/post-form-bridge.ts +53 -2
- package/src/{lib → client}/settings-bridge.ts +3 -15
- package/src/client/thread-context.ts +140 -0
- package/src/client/tiptap/bubble-menu.ts +205 -0
- package/src/client/tiptap/create-editor.ts +86 -0
- package/src/client/tiptap/exitable-marks.ts +73 -0
- package/src/client/tiptap/extensions.ts +65 -0
- package/src/client/tiptap/image-node.ts +482 -0
- package/src/client/tiptap/link-toolbar.ts +371 -0
- package/src/client/tiptap/more-break.ts +50 -0
- package/src/client/tiptap/paste-image.ts +129 -0
- package/src/client/tiptap/slash-commands.ts +438 -0
- package/src/{lib → client}/toast.ts +101 -3
- package/src/client/types/sortablejs.d.ts +44 -0
- package/src/client/upload-with-metadata.ts +54 -0
- package/src/client/video-processor.ts +207 -0
- package/src/client.ts +27 -17
- 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 -140
- 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 +783 -1087
- package/src/i18n/locales/en.ts +1 -1
- package/src/i18n/locales/zh-Hans.po +867 -812
- package/src/i18n/locales/zh-Hans.ts +1 -1
- package/src/i18n/locales/zh-Hant.po +878 -823
- 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__/resolve-config.test.ts +2 -2
- package/src/lib/__tests__/schemas.test.ts +186 -65
- 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__/url.test.ts +2 -2
- package/src/lib/__tests__/view.test.ts +140 -65
- package/src/lib/blurhash-placeholder.ts +102 -0
- package/src/lib/constants.ts +3 -1
- package/src/lib/emoji-catalog.ts +963 -0
- package/src/lib/errors.ts +11 -8
- package/src/lib/feed.ts +77 -31
- 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 +22 -12
- package/src/lib/nanoid.ts +29 -0
- package/src/lib/navigation.ts +1 -1
- package/src/lib/render.tsx +24 -5
- package/src/lib/resolve-config.ts +13 -2
- package/src/lib/schemas.ts +226 -58
- 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 +158 -0
- package/src/lib/theme.ts +11 -8
- package/src/lib/timeline.ts +76 -34
- package/src/lib/tiptap-render.ts +191 -0
- package/src/lib/tiptap-to-markdown.ts +305 -0
- package/src/lib/upload.ts +263 -14
- package/src/lib/url.ts +37 -22
- package/src/lib/view.ts +236 -55
- 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/error-handler.ts +3 -3
- package/src/middleware/onboarding.ts +1 -1
- package/src/middleware/secure-headers.ts +40 -0
- package/src/preset.css +83 -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 +57 -31
- 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 +81 -62
- package/src/routes/api/search.ts +3 -4
- package/src/routes/api/upload-multipart.ts +245 -0
- package/src/routes/api/upload.ts +92 -24
- package/src/routes/auth/__tests__/setup.test.ts +20 -60
- package/src/routes/auth/reset.tsx +5 -4
- package/src/routes/auth/setup.tsx +39 -31
- package/src/routes/auth/signin.tsx +13 -14
- package/src/routes/compose.tsx +27 -63
- package/src/routes/dash/__tests__/settings-avatar.test.ts +44 -9
- package/src/routes/dash/custom-urls.tsx +414 -0
- package/src/routes/dash/settings.tsx +475 -99
- package/src/routes/feed/__tests__/rss.test.ts +22 -23
- package/src/routes/feed/rss.ts +6 -2
- 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 +36 -18
- package/src/routes/pages/archive.tsx +177 -37
- package/src/routes/pages/collection.tsx +43 -14
- package/src/routes/pages/collections.tsx +11 -2
- package/src/routes/pages/featured.tsx +27 -3
- package/src/routes/pages/home.tsx +15 -14
- 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 +800 -230
- package/src/services/__tests__/search.test.ts +67 -10
- package/src/services/__tests__/settings.test.ts +3 -3
- 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 +764 -172
- package/src/services/search.ts +161 -74
- package/src/services/settings.ts +6 -2
- package/src/styles/components.css +293 -62
- package/src/styles/tokens.css +93 -5
- package/src/styles/ui.css +4349 -766
- package/src/types/bindings.ts +8 -0
- package/src/types/config.ts +34 -4
- package/src/types/constants.ts +17 -2
- package/src/types/entities.ts +83 -37
- package/src/types/operations.ts +20 -27
- package/src/types/props.ts +52 -17
- package/src/types/views.ts +48 -24
- package/src/ui/color-themes.ts +133 -23
- package/src/ui/compose/ComposeDialog.tsx +255 -16
- 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 +12 -2
- package/src/ui/dash/appearance/AdvancedContent.tsx +71 -59
- package/src/ui/dash/appearance/ColorThemeContent.tsx +48 -33
- package/src/ui/dash/appearance/FontThemeContent.tsx +65 -73
- package/src/ui/dash/appearance/NavigationContent.tsx +106 -135
- package/src/ui/dash/index.ts +0 -3
- package/src/ui/dash/settings/AccountContent.tsx +87 -146
- 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 +78 -0
- package/src/ui/dash/settings/GeneralContent.tsx +3 -62
- package/src/ui/dash/settings/SessionsContent.tsx +159 -0
- package/src/ui/dash/settings/SettingsRootContent.tsx +266 -0
- 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 +116 -103
- package/src/ui/pages/ArchivePage.tsx +923 -95
- package/src/ui/pages/CollectionPage.tsx +6 -35
- package/src/ui/pages/CollectionsPage.tsx +2 -1
- package/src/ui/pages/ComposePage.tsx +54 -0
- package/src/ui/pages/FeaturedPage.tsx +2 -1
- package/src/ui/pages/HomePage.tsx +1 -1
- package/src/ui/pages/PostPage.tsx +30 -45
- package/src/ui/pages/SearchPage.tsx +182 -38
- package/src/ui/shared/AdminBreadcrumb.tsx +29 -0
- package/src/ui/shared/CollectionsSidebar.tsx +239 -4
- package/src/ui/shared/MediaGallery.tsx +475 -41
- 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/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/meta/0003_snapshot.json +0 -821
- package/src/lib/__tests__/sqid.test.ts +0 -65
- package/src/lib/collections-reorder.ts +0 -28
- package/src/lib/compose-bridge.ts +0 -280
- package/src/lib/media-upload.ts +0 -148
- 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/appearance.tsx +0 -240
- package/src/routes/dash/collections.tsx +0 -211
- package/src/routes/dash/index.tsx +0 -103
- package/src/routes/dash/media.tsx +0 -132
- package/src/routes/dash/pages.tsx +0 -239
- package/src/routes/dash/posts.tsx +0 -334
- package/src/routes/dash/redirects.tsx +0 -257
- 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 -203
- package/src/services/path-registry.ts +0 -160
- package/src/services/redirect.ts +0 -97
- package/src/types/sortablejs.d.ts +0 -29
- package/src/ui/components/__tests__/jant-compose-dialog.test.ts +0 -512
- package/src/ui/components/__tests__/jant-compose-editor.test.ts +0 -272
- package/src/ui/components/compose-types.ts +0 -75
- package/src/ui/components/jant-collection-form.ts +0 -512
- package/src/ui/components/jant-compose-dialog.ts +0 -495
- package/src/ui/components/jant-compose-editor.ts +0 -814
- package/src/ui/dash/PageForm.tsx +0 -185
- package/src/ui/dash/PostList.tsx +0 -95
- package/src/ui/dash/appearance/AppearanceNav.tsx +0 -60
- package/src/ui/dash/collections/CollectionForm.tsx +0 -166
- package/src/ui/dash/collections/CollectionsListContent.tsx +0 -146
- package/src/ui/dash/collections/IconPickerGrid.tsx +0 -50
- package/src/ui/dash/collections/ViewCollectionContent.tsx +0 -103
- package/src/ui/dash/media/MediaListContent.tsx +0 -201
- package/src/ui/dash/media/ViewMediaContent.tsx +0 -208
- package/src/ui/dash/pages/PagesContent.tsx +0 -74
- package/src/ui/dash/posts/PostForm.tsx +0 -248
- package/src/ui/dash/settings/SettingsNav.tsx +0 -52
- package/src/ui/layouts/DashLayout.tsx +0 -165
- package/src/ui/pages/SinglePage.tsx +0 -23
- package/src/ui/shared/ThreadView.tsx +0 -136
- /package/src/{ui → client}/components/settings-types.ts +0 -0
|
@@ -1,72 +1,113 @@
|
|
|
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", () => {
|
|
24
20
|
it("creates a note post with required fields", async () => {
|
|
21
|
+
const body = JSON.stringify({
|
|
22
|
+
type: "doc",
|
|
23
|
+
content: [
|
|
24
|
+
{
|
|
25
|
+
type: "paragraph",
|
|
26
|
+
content: [{ type: "text", text: "Hello world" }],
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
});
|
|
25
30
|
const post = await postService.create({
|
|
26
31
|
format: "note",
|
|
27
|
-
body
|
|
32
|
+
body,
|
|
28
33
|
});
|
|
29
34
|
|
|
30
|
-
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
|
+
);
|
|
31
39
|
expect(post.format).toBe("note");
|
|
32
|
-
expect(post.body).toBe(
|
|
40
|
+
expect(post.body).toBe(body);
|
|
33
41
|
expect(post.status).toBe("published"); // default
|
|
34
|
-
expect(post.
|
|
35
|
-
expect(post.
|
|
42
|
+
expect(post.visibility).toBe("public");
|
|
43
|
+
expect(post.pinnedAt).toBeNull();
|
|
36
44
|
expect(post.bodyHtml).toContain("<p>Hello world</p>");
|
|
37
45
|
expect(post.deletedAt).toBeNull();
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
46
|
+
expect(post.threadId).toBe(post.id);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("creates a link post with commentary", async () => {
|
|
50
|
+
const body = JSON.stringify({
|
|
51
|
+
type: "doc",
|
|
52
|
+
content: [
|
|
53
|
+
{
|
|
54
|
+
type: "heading",
|
|
55
|
+
attrs: { level: 1 },
|
|
56
|
+
content: [{ type: "text", text: "Introduction" }],
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
type: "paragraph",
|
|
60
|
+
content: [{ type: "text", text: "Some content." }],
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
});
|
|
41
64
|
const post = await postService.create({
|
|
42
65
|
format: "link",
|
|
43
66
|
title: "My Link",
|
|
44
|
-
body
|
|
67
|
+
body,
|
|
45
68
|
status: "published",
|
|
69
|
+
visibility: "public",
|
|
46
70
|
featured: true,
|
|
47
71
|
pinned: true,
|
|
48
|
-
|
|
72
|
+
slug: "my-link",
|
|
49
73
|
url: "https://example.com/source",
|
|
50
|
-
quoteText: "A notable quote",
|
|
51
74
|
rating: 5,
|
|
52
75
|
});
|
|
53
76
|
|
|
54
77
|
expect(post.format).toBe("link");
|
|
55
78
|
expect(post.title).toBe("My Link");
|
|
56
79
|
expect(post.status).toBe("published");
|
|
57
|
-
expect(post.
|
|
58
|
-
expect(post.
|
|
59
|
-
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");
|
|
60
84
|
expect(post.url).toBe("https://example.com/source");
|
|
61
|
-
expect(post.quoteText).
|
|
85
|
+
expect(post.quoteText).toBeNull();
|
|
62
86
|
expect(post.rating).toBe(5);
|
|
63
87
|
expect(post.bodyHtml).toContain("<h1>");
|
|
64
88
|
});
|
|
65
89
|
|
|
66
|
-
it("renders
|
|
90
|
+
it("renders Tiptap JSON body to HTML", async () => {
|
|
91
|
+
const body = JSON.stringify({
|
|
92
|
+
type: "doc",
|
|
93
|
+
content: [
|
|
94
|
+
{
|
|
95
|
+
type: "paragraph",
|
|
96
|
+
content: [
|
|
97
|
+
{ type: "text", text: "This is " },
|
|
98
|
+
{
|
|
99
|
+
type: "text",
|
|
100
|
+
marks: [{ type: "bold" }],
|
|
101
|
+
text: "bold",
|
|
102
|
+
},
|
|
103
|
+
{ type: "text", text: " text" },
|
|
104
|
+
],
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
});
|
|
67
108
|
const post = await postService.create({
|
|
68
109
|
format: "note",
|
|
69
|
-
body
|
|
110
|
+
body,
|
|
70
111
|
});
|
|
71
112
|
|
|
72
113
|
expect(post.bodyHtml).toContain("<strong>bold</strong>");
|
|
@@ -75,7 +116,7 @@ describe("PostService", () => {
|
|
|
75
116
|
it("sets publishedAt and timestamps", async () => {
|
|
76
117
|
const post = await postService.create({
|
|
77
118
|
format: "note",
|
|
78
|
-
|
|
119
|
+
bodyMarkdown: "test",
|
|
79
120
|
});
|
|
80
121
|
|
|
81
122
|
expect(post.publishedAt).toBeGreaterThan(0);
|
|
@@ -87,31 +128,33 @@ describe("PostService", () => {
|
|
|
87
128
|
const customTime = 1706745600;
|
|
88
129
|
const post = await postService.create({
|
|
89
130
|
format: "note",
|
|
90
|
-
|
|
131
|
+
bodyMarkdown: "test",
|
|
91
132
|
publishedAt: customTime,
|
|
92
133
|
});
|
|
93
134
|
|
|
94
135
|
expect(post.publishedAt).toBe(customTime);
|
|
95
136
|
});
|
|
96
137
|
|
|
97
|
-
it("creates
|
|
138
|
+
it("creates unique UUIDv7 IDs that sort chronologically", async () => {
|
|
98
139
|
const post1 = await postService.create({
|
|
99
140
|
format: "note",
|
|
100
|
-
|
|
141
|
+
bodyMarkdown: "first",
|
|
101
142
|
});
|
|
102
143
|
const post2 = await postService.create({
|
|
103
144
|
format: "note",
|
|
104
|
-
|
|
145
|
+
bodyMarkdown: "second",
|
|
105
146
|
});
|
|
106
147
|
|
|
107
|
-
expect(
|
|
148
|
+
expect(post1.id).not.toBe(post2.id);
|
|
149
|
+
// UUIDv7 strings sort chronologically
|
|
150
|
+
expect(post2.id > post1.id).toBe(true);
|
|
108
151
|
});
|
|
109
152
|
|
|
110
153
|
it("creates a quote post", async () => {
|
|
111
154
|
const post = await postService.create({
|
|
112
155
|
format: "quote",
|
|
113
156
|
quoteText: "To be or not to be",
|
|
114
|
-
|
|
157
|
+
bodyMarkdown: "Shakespeare's famous line",
|
|
115
158
|
url: "https://example.com/hamlet",
|
|
116
159
|
});
|
|
117
160
|
|
|
@@ -123,11 +166,110 @@ describe("PostService", () => {
|
|
|
123
166
|
it("creates a draft post", async () => {
|
|
124
167
|
const post = await postService.create({
|
|
125
168
|
format: "note",
|
|
126
|
-
|
|
169
|
+
bodyMarkdown: "draft content",
|
|
127
170
|
status: "draft",
|
|
128
171
|
});
|
|
129
172
|
|
|
130
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);
|
|
131
273
|
});
|
|
132
274
|
});
|
|
133
275
|
|
|
@@ -135,13 +277,13 @@ describe("PostService", () => {
|
|
|
135
277
|
it("returns a post by ID", async () => {
|
|
136
278
|
const created = await postService.create({
|
|
137
279
|
format: "note",
|
|
138
|
-
|
|
280
|
+
bodyMarkdown: "test",
|
|
139
281
|
});
|
|
140
282
|
|
|
141
283
|
const found = await postService.getById(created.id);
|
|
142
284
|
expect(found).not.toBeNull();
|
|
143
285
|
expect(found?.id).toBe(created.id);
|
|
144
|
-
expect(found?.
|
|
286
|
+
expect(found?.bodyText).toBe("test");
|
|
145
287
|
});
|
|
146
288
|
|
|
147
289
|
it("returns null for non-existent ID", async () => {
|
|
@@ -152,7 +294,7 @@ describe("PostService", () => {
|
|
|
152
294
|
it("excludes soft-deleted posts", async () => {
|
|
153
295
|
const post = await postService.create({
|
|
154
296
|
format: "note",
|
|
155
|
-
|
|
297
|
+
bodyMarkdown: "test",
|
|
156
298
|
});
|
|
157
299
|
await postService.delete(post.id);
|
|
158
300
|
|
|
@@ -161,47 +303,35 @@ describe("PostService", () => {
|
|
|
161
303
|
});
|
|
162
304
|
});
|
|
163
305
|
|
|
164
|
-
describe("
|
|
165
|
-
it("returns a post by
|
|
306
|
+
describe("getBySlug", () => {
|
|
307
|
+
it("returns a post by slug", async () => {
|
|
166
308
|
await postService.create({
|
|
167
309
|
format: "note",
|
|
168
|
-
|
|
169
|
-
|
|
310
|
+
bodyMarkdown: "About page",
|
|
311
|
+
slug: "about",
|
|
170
312
|
});
|
|
171
313
|
|
|
172
|
-
const found = await postService.
|
|
314
|
+
const found = await postService.getBySlug("about");
|
|
173
315
|
expect(found).not.toBeNull();
|
|
174
|
-
expect(found?.
|
|
316
|
+
expect(found?.slug).toBe("about");
|
|
175
317
|
});
|
|
176
318
|
|
|
177
|
-
it("returns null for non-existent
|
|
178
|
-
const found = await postService.
|
|
319
|
+
it("returns null for non-existent slug", async () => {
|
|
320
|
+
const found = await postService.getBySlug("nonexistent");
|
|
179
321
|
expect(found).toBeNull();
|
|
180
322
|
});
|
|
181
323
|
|
|
182
324
|
it("excludes soft-deleted posts", async () => {
|
|
183
325
|
const post = await postService.create({
|
|
184
326
|
format: "note",
|
|
185
|
-
|
|
186
|
-
|
|
327
|
+
bodyMarkdown: "test",
|
|
328
|
+
slug: "test-page",
|
|
187
329
|
});
|
|
188
330
|
await postService.delete(post.id);
|
|
189
331
|
|
|
190
|
-
const found = await postService.
|
|
332
|
+
const found = await postService.getBySlug("test-page");
|
|
191
333
|
expect(found).toBeNull();
|
|
192
334
|
});
|
|
193
|
-
|
|
194
|
-
it("finds a post with a multi-level path", async () => {
|
|
195
|
-
await postService.create({
|
|
196
|
-
format: "note",
|
|
197
|
-
body: "Blog migration",
|
|
198
|
-
path: "2024/01/my-post",
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
const found = await postService.getByPath("2024/01/my-post");
|
|
202
|
-
expect(found).not.toBeNull();
|
|
203
|
-
expect(found?.path).toBe("2024/01/my-post");
|
|
204
|
-
});
|
|
205
335
|
});
|
|
206
336
|
|
|
207
337
|
describe("list", () => {
|
|
@@ -211,9 +341,9 @@ describe("PostService", () => {
|
|
|
211
341
|
});
|
|
212
342
|
|
|
213
343
|
it("returns all non-deleted posts", async () => {
|
|
214
|
-
await postService.create({ format: "note",
|
|
215
|
-
await postService.create({ format: "note",
|
|
216
|
-
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" });
|
|
217
347
|
|
|
218
348
|
const posts = await postService.list();
|
|
219
349
|
expect(posts).toHaveLength(3);
|
|
@@ -222,25 +352,50 @@ describe("PostService", () => {
|
|
|
222
352
|
it("orders by publishedAt descending", async () => {
|
|
223
353
|
await postService.create({
|
|
224
354
|
format: "note",
|
|
225
|
-
|
|
355
|
+
bodyMarkdown: "old",
|
|
226
356
|
publishedAt: 1000,
|
|
227
357
|
});
|
|
228
358
|
await postService.create({
|
|
229
359
|
format: "note",
|
|
230
|
-
|
|
360
|
+
bodyMarkdown: "new",
|
|
231
361
|
publishedAt: 2000,
|
|
232
362
|
});
|
|
233
363
|
|
|
234
364
|
const posts = await postService.list();
|
|
235
|
-
expect(posts[0]?.
|
|
236
|
-
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);
|
|
237
392
|
});
|
|
238
393
|
|
|
239
394
|
it("filters by format", async () => {
|
|
240
|
-
await postService.create({ format: "note",
|
|
395
|
+
await postService.create({ format: "note", bodyMarkdown: "a note" });
|
|
241
396
|
await postService.create({
|
|
242
397
|
format: "link",
|
|
243
|
-
|
|
398
|
+
bodyMarkdown: "a link",
|
|
244
399
|
title: "Link",
|
|
245
400
|
url: "https://example.com",
|
|
246
401
|
});
|
|
@@ -253,12 +408,12 @@ describe("PostService", () => {
|
|
|
253
408
|
it("filters by status", async () => {
|
|
254
409
|
await postService.create({
|
|
255
410
|
format: "note",
|
|
256
|
-
|
|
411
|
+
bodyMarkdown: "published post",
|
|
257
412
|
status: "published",
|
|
258
413
|
});
|
|
259
414
|
await postService.create({
|
|
260
415
|
format: "note",
|
|
261
|
-
|
|
416
|
+
bodyMarkdown: "draft post",
|
|
262
417
|
status: "draft",
|
|
263
418
|
});
|
|
264
419
|
|
|
@@ -267,67 +422,149 @@ describe("PostService", () => {
|
|
|
267
422
|
expect(published[0]?.status).toBe("published");
|
|
268
423
|
});
|
|
269
424
|
|
|
425
|
+
it("filters by visibility", async () => {
|
|
426
|
+
await postService.create({
|
|
427
|
+
format: "note",
|
|
428
|
+
bodyMarkdown: "public post",
|
|
429
|
+
});
|
|
430
|
+
await postService.create({
|
|
431
|
+
format: "note",
|
|
432
|
+
bodyMarkdown: "unlisted post",
|
|
433
|
+
visibility: "unlisted",
|
|
434
|
+
});
|
|
435
|
+
await postService.create({
|
|
436
|
+
format: "note",
|
|
437
|
+
bodyMarkdown: "private post",
|
|
438
|
+
visibility: "private",
|
|
439
|
+
});
|
|
440
|
+
|
|
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");
|
|
445
|
+
|
|
446
|
+
const unlisted = await postService.list({ visibility: "unlisted" });
|
|
447
|
+
expect(unlisted).toHaveLength(1);
|
|
448
|
+
expect(unlisted[0]?.visibility).toBe("unlisted");
|
|
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
|
+
|
|
270
457
|
it("filters by featured", async () => {
|
|
271
458
|
await postService.create({
|
|
272
459
|
format: "note",
|
|
273
|
-
|
|
460
|
+
bodyMarkdown: "featured post",
|
|
274
461
|
featured: true,
|
|
275
462
|
});
|
|
276
463
|
await postService.create({
|
|
277
464
|
format: "note",
|
|
278
|
-
|
|
465
|
+
bodyMarkdown: "normal post",
|
|
279
466
|
});
|
|
280
467
|
|
|
281
468
|
const featured = await postService.list({ featured: true });
|
|
282
469
|
expect(featured).toHaveLength(1);
|
|
283
|
-
expect(featured[0]?.
|
|
284
|
-
expect(featured[0]?.
|
|
470
|
+
expect(featured[0]?.featuredAt).toBeTypeOf("number");
|
|
471
|
+
expect(featured[0]?.bodyText).toBe("featured post");
|
|
285
472
|
|
|
286
473
|
const notFeatured = await postService.list({ featured: false });
|
|
287
474
|
expect(notFeatured).toHaveLength(1);
|
|
288
|
-
expect(notFeatured[0]?.
|
|
289
|
-
expect(notFeatured[0]?.
|
|
475
|
+
expect(notFeatured[0]?.featuredAt).toBeNull();
|
|
476
|
+
expect(notFeatured[0]?.bodyText).toBe("normal post");
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
it("excludes unlisted posts when requested", async () => {
|
|
480
|
+
await postService.create({
|
|
481
|
+
format: "note",
|
|
482
|
+
bodyMarkdown: "public post",
|
|
483
|
+
});
|
|
484
|
+
await postService.create({
|
|
485
|
+
format: "note",
|
|
486
|
+
bodyMarkdown: "unlisted post",
|
|
487
|
+
visibility: "unlisted",
|
|
488
|
+
});
|
|
489
|
+
await postService.create({
|
|
490
|
+
format: "note",
|
|
491
|
+
bodyMarkdown: "featured post",
|
|
492
|
+
featured: true,
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
const posts = await postService.list({ excludeUnlisted: true });
|
|
496
|
+
expect(posts).toHaveLength(2);
|
|
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([
|
|
524
|
+
"featured post",
|
|
525
|
+
"public post",
|
|
526
|
+
]);
|
|
290
527
|
});
|
|
291
528
|
|
|
292
529
|
it("filters by pinned", async () => {
|
|
293
530
|
await postService.create({
|
|
294
531
|
format: "note",
|
|
295
|
-
|
|
532
|
+
bodyMarkdown: "pinned post",
|
|
296
533
|
pinned: true,
|
|
297
534
|
});
|
|
298
535
|
await postService.create({
|
|
299
536
|
format: "note",
|
|
300
|
-
|
|
537
|
+
bodyMarkdown: "normal post",
|
|
301
538
|
});
|
|
302
539
|
|
|
303
540
|
const pinned = await postService.list({ pinned: true });
|
|
304
541
|
expect(pinned).toHaveLength(1);
|
|
305
|
-
expect(pinned[0]?.
|
|
306
|
-
expect(pinned[0]?.
|
|
542
|
+
expect(pinned[0]?.pinnedAt).toBeTypeOf("number");
|
|
543
|
+
expect(pinned[0]?.bodyText).toBe("pinned post");
|
|
307
544
|
|
|
308
545
|
const notPinned = await postService.list({ pinned: false });
|
|
309
546
|
expect(notPinned).toHaveLength(1);
|
|
310
|
-
expect(notPinned[0]?.
|
|
311
|
-
expect(notPinned[0]?.
|
|
547
|
+
expect(notPinned[0]?.pinnedAt).toBeNull();
|
|
548
|
+
expect(notPinned[0]?.bodyText).toBe("normal post");
|
|
312
549
|
});
|
|
313
550
|
|
|
314
551
|
it("excludes deleted posts by default", async () => {
|
|
315
552
|
const post = await postService.create({
|
|
316
553
|
format: "note",
|
|
317
|
-
|
|
554
|
+
bodyMarkdown: "test",
|
|
318
555
|
});
|
|
319
|
-
await postService.create({ format: "note",
|
|
556
|
+
await postService.create({ format: "note", bodyMarkdown: "kept" });
|
|
320
557
|
await postService.delete(post.id);
|
|
321
558
|
|
|
322
559
|
const posts = await postService.list();
|
|
323
560
|
expect(posts).toHaveLength(1);
|
|
324
|
-
expect(posts[0]?.
|
|
561
|
+
expect(posts[0]?.bodyText).toBe("kept");
|
|
325
562
|
});
|
|
326
563
|
|
|
327
564
|
it("includes deleted posts when requested", async () => {
|
|
328
565
|
const post = await postService.create({
|
|
329
566
|
format: "note",
|
|
330
|
-
|
|
567
|
+
bodyMarkdown: "test",
|
|
331
568
|
});
|
|
332
569
|
await postService.delete(post.id);
|
|
333
570
|
|
|
@@ -337,7 +574,7 @@ describe("PostService", () => {
|
|
|
337
574
|
|
|
338
575
|
it("supports limit", async () => {
|
|
339
576
|
for (let i = 0; i < 5; i++) {
|
|
340
|
-
await postService.create({ format: "note",
|
|
577
|
+
await postService.create({ format: "note", bodyMarkdown: `post ${i}` });
|
|
341
578
|
}
|
|
342
579
|
|
|
343
580
|
const posts = await postService.list({ limit: 2 });
|
|
@@ -350,7 +587,7 @@ describe("PostService", () => {
|
|
|
350
587
|
created.push(
|
|
351
588
|
await postService.create({
|
|
352
589
|
format: "note",
|
|
353
|
-
|
|
590
|
+
bodyMarkdown: `post ${i}`,
|
|
354
591
|
publishedAt: 1000 + i,
|
|
355
592
|
}),
|
|
356
593
|
);
|
|
@@ -365,24 +602,24 @@ describe("PostService", () => {
|
|
|
365
602
|
it("excludes replies when requested", async () => {
|
|
366
603
|
const root = await postService.create({
|
|
367
604
|
format: "note",
|
|
368
|
-
|
|
605
|
+
bodyMarkdown: "root post",
|
|
369
606
|
});
|
|
370
607
|
await postService.create({
|
|
371
608
|
format: "note",
|
|
372
|
-
|
|
609
|
+
bodyMarkdown: "reply",
|
|
373
610
|
replyToId: root.id,
|
|
374
611
|
});
|
|
375
612
|
|
|
376
613
|
const posts = await postService.list({ excludeReplies: true });
|
|
377
614
|
expect(posts).toHaveLength(1);
|
|
378
|
-
expect(posts[0]?.
|
|
615
|
+
expect(posts[0]?.bodyText).toBe("root post");
|
|
379
616
|
});
|
|
380
617
|
|
|
381
618
|
it("supports offset pagination", async () => {
|
|
382
619
|
for (let i = 0; i < 5; i++) {
|
|
383
620
|
await postService.create({
|
|
384
621
|
format: "note",
|
|
385
|
-
|
|
622
|
+
bodyMarkdown: `post ${i}`,
|
|
386
623
|
publishedAt: 1000 + i,
|
|
387
624
|
});
|
|
388
625
|
}
|
|
@@ -390,8 +627,8 @@ describe("PostService", () => {
|
|
|
390
627
|
// Skip the first 2 posts (newest), get 2 more
|
|
391
628
|
const posts = await postService.list({ limit: 2, offset: 2 });
|
|
392
629
|
expect(posts).toHaveLength(2);
|
|
393
|
-
expect(posts[0]?.
|
|
394
|
-
expect(posts[1]?.
|
|
630
|
+
expect(posts[0]?.bodyText).toBe("post 2");
|
|
631
|
+
expect(posts[1]?.bodyText).toBe("post 1");
|
|
395
632
|
});
|
|
396
633
|
});
|
|
397
634
|
|
|
@@ -402,9 +639,9 @@ describe("PostService", () => {
|
|
|
402
639
|
});
|
|
403
640
|
|
|
404
641
|
it("counts all non-deleted posts", async () => {
|
|
405
|
-
await postService.create({ format: "note",
|
|
406
|
-
await postService.create({ format: "note",
|
|
407
|
-
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" });
|
|
408
645
|
|
|
409
646
|
const count = await postService.count();
|
|
410
647
|
expect(count).toBe(3);
|
|
@@ -413,12 +650,12 @@ describe("PostService", () => {
|
|
|
413
650
|
it("filters by status", async () => {
|
|
414
651
|
await postService.create({
|
|
415
652
|
format: "note",
|
|
416
|
-
|
|
653
|
+
bodyMarkdown: "published",
|
|
417
654
|
status: "published",
|
|
418
655
|
});
|
|
419
656
|
await postService.create({
|
|
420
657
|
format: "note",
|
|
421
|
-
|
|
658
|
+
bodyMarkdown: "draft",
|
|
422
659
|
status: "draft",
|
|
423
660
|
});
|
|
424
661
|
|
|
@@ -426,24 +663,39 @@ describe("PostService", () => {
|
|
|
426
663
|
expect(count).toBe(1);
|
|
427
664
|
});
|
|
428
665
|
|
|
666
|
+
it("filters by visibility", async () => {
|
|
667
|
+
await postService.create({
|
|
668
|
+
format: "note",
|
|
669
|
+
bodyMarkdown: "unlisted",
|
|
670
|
+
visibility: "unlisted",
|
|
671
|
+
});
|
|
672
|
+
await postService.create({ format: "note", bodyMarkdown: "normal" });
|
|
673
|
+
|
|
674
|
+
const count = await postService.count({ visibility: "unlisted" });
|
|
675
|
+
expect(count).toBe(1);
|
|
676
|
+
});
|
|
677
|
+
|
|
429
678
|
it("filters by featured", async () => {
|
|
430
679
|
await postService.create({
|
|
431
680
|
format: "note",
|
|
432
|
-
|
|
681
|
+
bodyMarkdown: "featured",
|
|
433
682
|
featured: true,
|
|
434
683
|
});
|
|
435
|
-
await postService.create({ format: "note",
|
|
684
|
+
await postService.create({ format: "note", bodyMarkdown: "normal" });
|
|
436
685
|
|
|
437
|
-
const
|
|
438
|
-
expect(
|
|
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);
|
|
439
691
|
});
|
|
440
692
|
|
|
441
693
|
it("excludes deleted posts by default", async () => {
|
|
442
694
|
const post = await postService.create({
|
|
443
695
|
format: "note",
|
|
444
|
-
|
|
696
|
+
bodyMarkdown: "to delete",
|
|
445
697
|
});
|
|
446
|
-
await postService.create({ format: "note",
|
|
698
|
+
await postService.create({ format: "note", bodyMarkdown: "keep" });
|
|
447
699
|
await postService.delete(post.id);
|
|
448
700
|
|
|
449
701
|
const count = await postService.count();
|
|
@@ -453,11 +705,11 @@ describe("PostService", () => {
|
|
|
453
705
|
it("excludes replies when requested", async () => {
|
|
454
706
|
const root = await postService.create({
|
|
455
707
|
format: "note",
|
|
456
|
-
|
|
708
|
+
bodyMarkdown: "root",
|
|
457
709
|
});
|
|
458
710
|
await postService.create({
|
|
459
711
|
format: "note",
|
|
460
|
-
|
|
712
|
+
bodyMarkdown: "reply",
|
|
461
713
|
replyToId: root.id,
|
|
462
714
|
});
|
|
463
715
|
|
|
@@ -470,22 +722,39 @@ describe("PostService", () => {
|
|
|
470
722
|
it("updates post body", async () => {
|
|
471
723
|
const post = await postService.create({
|
|
472
724
|
format: "note",
|
|
473
|
-
body:
|
|
725
|
+
body: JSON.stringify({
|
|
726
|
+
type: "doc",
|
|
727
|
+
content: [
|
|
728
|
+
{
|
|
729
|
+
type: "paragraph",
|
|
730
|
+
content: [{ type: "text", text: "original" }],
|
|
731
|
+
},
|
|
732
|
+
],
|
|
733
|
+
}),
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
const updatedBody = JSON.stringify({
|
|
737
|
+
type: "doc",
|
|
738
|
+
content: [
|
|
739
|
+
{
|
|
740
|
+
type: "paragraph",
|
|
741
|
+
content: [{ type: "text", text: "updated content" }],
|
|
742
|
+
},
|
|
743
|
+
],
|
|
474
744
|
});
|
|
475
|
-
|
|
476
745
|
const updated = await postService.update(post.id, {
|
|
477
|
-
body:
|
|
746
|
+
body: updatedBody,
|
|
478
747
|
});
|
|
479
748
|
|
|
480
749
|
expect(updated).not.toBeNull();
|
|
481
|
-
expect(updated?.body).toBe(
|
|
750
|
+
expect(updated?.body).toBe(updatedBody);
|
|
482
751
|
expect(updated?.bodyHtml).toContain("updated content");
|
|
483
752
|
});
|
|
484
753
|
|
|
485
754
|
it("updates post title", async () => {
|
|
486
755
|
const post = await postService.create({
|
|
487
756
|
format: "link",
|
|
488
|
-
|
|
757
|
+
bodyMarkdown: "body",
|
|
489
758
|
title: "Original Title",
|
|
490
759
|
url: "https://example.com",
|
|
491
760
|
});
|
|
@@ -500,7 +769,7 @@ describe("PostService", () => {
|
|
|
500
769
|
it("updates post url", async () => {
|
|
501
770
|
const post = await postService.create({
|
|
502
771
|
format: "link",
|
|
503
|
-
|
|
772
|
+
bodyMarkdown: "link post",
|
|
504
773
|
url: "https://old.com",
|
|
505
774
|
});
|
|
506
775
|
|
|
@@ -511,29 +780,29 @@ describe("PostService", () => {
|
|
|
511
780
|
expect(updated?.url).toBe("https://new-source.com/path");
|
|
512
781
|
});
|
|
513
782
|
|
|
514
|
-
it("
|
|
783
|
+
it("rejects clearing url from a link post", async () => {
|
|
515
784
|
const post = await postService.create({
|
|
516
785
|
format: "link",
|
|
517
|
-
|
|
786
|
+
bodyMarkdown: "test",
|
|
518
787
|
url: "https://example.com",
|
|
519
788
|
});
|
|
520
789
|
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
790
|
+
await expect(
|
|
791
|
+
postService.update(post.id, {
|
|
792
|
+
url: null,
|
|
793
|
+
}),
|
|
794
|
+
).rejects.toThrow("Link posts need a URL.");
|
|
526
795
|
});
|
|
527
796
|
|
|
528
797
|
it("returns null for non-existent post", async () => {
|
|
529
|
-
const result = await postService.update(9999, {
|
|
798
|
+
const result = await postService.update(9999, { bodyMarkdown: "test" });
|
|
530
799
|
expect(result).toBeNull();
|
|
531
800
|
});
|
|
532
801
|
|
|
533
802
|
it("updates updatedAt timestamp", async () => {
|
|
534
803
|
const post = await postService.create({
|
|
535
804
|
format: "note",
|
|
536
|
-
|
|
805
|
+
bodyMarkdown: "test",
|
|
537
806
|
});
|
|
538
807
|
const originalUpdatedAt = post.updatedAt;
|
|
539
808
|
|
|
@@ -541,54 +810,124 @@ describe("PostService", () => {
|
|
|
541
810
|
await new Promise((r) => setTimeout(r, 1100));
|
|
542
811
|
|
|
543
812
|
const updated = await postService.update(post.id, {
|
|
544
|
-
|
|
813
|
+
bodyMarkdown: "modified",
|
|
545
814
|
});
|
|
546
815
|
|
|
547
816
|
expect(updated?.updatedAt).toBeGreaterThanOrEqual(originalUpdatedAt);
|
|
548
817
|
});
|
|
549
818
|
|
|
550
|
-
it("
|
|
819
|
+
it("sets publishedAt when publishing a draft", async () => {
|
|
551
820
|
const post = await postService.create({
|
|
552
821
|
format: "note",
|
|
553
|
-
|
|
822
|
+
bodyMarkdown: "draft",
|
|
823
|
+
status: "draft",
|
|
554
824
|
});
|
|
555
825
|
|
|
556
|
-
expect(post.
|
|
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
|
+
|
|
868
|
+
it("updates visibility", async () => {
|
|
869
|
+
const post = await postService.create({
|
|
870
|
+
format: "note",
|
|
871
|
+
bodyMarkdown: "test",
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
expect(post.visibility).toBe("public");
|
|
557
875
|
|
|
558
876
|
const updated = await postService.update(post.id, {
|
|
877
|
+
visibility: "unlisted",
|
|
878
|
+
});
|
|
879
|
+
|
|
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, {
|
|
559
892
|
featured: true,
|
|
560
893
|
});
|
|
561
894
|
|
|
562
|
-
expect(
|
|
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();
|
|
563
902
|
});
|
|
564
903
|
|
|
565
904
|
it("updates pinned flag", async () => {
|
|
566
905
|
const post = await postService.create({
|
|
567
906
|
format: "note",
|
|
568
|
-
|
|
907
|
+
bodyMarkdown: "test",
|
|
569
908
|
});
|
|
570
909
|
|
|
571
|
-
expect(post.
|
|
910
|
+
expect(post.pinnedAt).toBeNull();
|
|
572
911
|
|
|
573
912
|
const updated = await postService.update(post.id, {
|
|
574
913
|
pinned: true,
|
|
575
914
|
});
|
|
576
915
|
|
|
577
|
-
expect(updated?.
|
|
916
|
+
expect(updated?.pinnedAt).toBeTypeOf("number");
|
|
578
917
|
});
|
|
579
918
|
|
|
580
|
-
it("updates
|
|
919
|
+
it("updates slug", async () => {
|
|
581
920
|
const post = await postService.create({
|
|
582
921
|
format: "note",
|
|
583
|
-
|
|
584
|
-
|
|
922
|
+
bodyMarkdown: "test",
|
|
923
|
+
slug: "old-slug",
|
|
585
924
|
});
|
|
586
925
|
|
|
587
926
|
const updated = await postService.update(post.id, {
|
|
588
|
-
|
|
927
|
+
slug: "new-slug",
|
|
589
928
|
});
|
|
590
929
|
|
|
591
|
-
expect(updated?.
|
|
930
|
+
expect(updated?.slug).toBe("new-slug");
|
|
592
931
|
});
|
|
593
932
|
|
|
594
933
|
it("updates quoteText and rating", async () => {
|
|
@@ -606,13 +945,40 @@ describe("PostService", () => {
|
|
|
606
945
|
expect(updated?.quoteText).toBe("Updated quote");
|
|
607
946
|
expect(updated?.rating).toBe(5);
|
|
608
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
|
+
});
|
|
609
975
|
});
|
|
610
976
|
|
|
611
977
|
describe("delete (soft delete)", () => {
|
|
612
978
|
it("soft-deletes a post", async () => {
|
|
613
979
|
const post = await postService.create({
|
|
614
980
|
format: "note",
|
|
615
|
-
|
|
981
|
+
bodyMarkdown: "test",
|
|
616
982
|
});
|
|
617
983
|
|
|
618
984
|
const result = await postService.delete(post.id);
|
|
@@ -631,11 +997,11 @@ describe("PostService", () => {
|
|
|
631
997
|
it("cascade deletes thread when deleting root post", async () => {
|
|
632
998
|
const root = await postService.create({
|
|
633
999
|
format: "note",
|
|
634
|
-
|
|
1000
|
+
bodyMarkdown: "root",
|
|
635
1001
|
});
|
|
636
1002
|
const reply = await postService.create({
|
|
637
1003
|
format: "note",
|
|
638
|
-
|
|
1004
|
+
bodyMarkdown: "reply",
|
|
639
1005
|
replyToId: root.id,
|
|
640
1006
|
});
|
|
641
1007
|
|
|
@@ -649,16 +1015,16 @@ describe("PostService", () => {
|
|
|
649
1015
|
it("only deletes single post when deleting a reply", async () => {
|
|
650
1016
|
const root = await postService.create({
|
|
651
1017
|
format: "note",
|
|
652
|
-
|
|
1018
|
+
bodyMarkdown: "root",
|
|
653
1019
|
});
|
|
654
1020
|
const reply1 = await postService.create({
|
|
655
1021
|
format: "note",
|
|
656
|
-
|
|
1022
|
+
bodyMarkdown: "reply1",
|
|
657
1023
|
replyToId: root.id,
|
|
658
1024
|
});
|
|
659
1025
|
await postService.create({
|
|
660
1026
|
format: "note",
|
|
661
|
-
|
|
1027
|
+
bodyMarkdown: "reply2",
|
|
662
1028
|
replyToId: root.id,
|
|
663
1029
|
});
|
|
664
1030
|
|
|
@@ -675,11 +1041,11 @@ describe("PostService", () => {
|
|
|
675
1041
|
it("sets threadId on reply to a root post", async () => {
|
|
676
1042
|
const root = await postService.create({
|
|
677
1043
|
format: "note",
|
|
678
|
-
|
|
1044
|
+
bodyMarkdown: "root",
|
|
679
1045
|
});
|
|
680
1046
|
const reply = await postService.create({
|
|
681
1047
|
format: "note",
|
|
682
|
-
|
|
1048
|
+
bodyMarkdown: "reply",
|
|
683
1049
|
replyToId: root.id,
|
|
684
1050
|
});
|
|
685
1051
|
|
|
@@ -690,16 +1056,16 @@ describe("PostService", () => {
|
|
|
690
1056
|
it("inherits threadId from parent in nested replies", async () => {
|
|
691
1057
|
const root = await postService.create({
|
|
692
1058
|
format: "note",
|
|
693
|
-
|
|
1059
|
+
bodyMarkdown: "root",
|
|
694
1060
|
});
|
|
695
1061
|
const reply1 = await postService.create({
|
|
696
1062
|
format: "note",
|
|
697
|
-
|
|
1063
|
+
bodyMarkdown: "reply1",
|
|
698
1064
|
replyToId: root.id,
|
|
699
1065
|
});
|
|
700
1066
|
const reply2 = await postService.create({
|
|
701
1067
|
format: "note",
|
|
702
|
-
|
|
1068
|
+
bodyMarkdown: "reply2",
|
|
703
1069
|
replyToId: reply1.id,
|
|
704
1070
|
});
|
|
705
1071
|
|
|
@@ -711,63 +1077,121 @@ describe("PostService", () => {
|
|
|
711
1077
|
it("inherits status from root post", async () => {
|
|
712
1078
|
const root = await postService.create({
|
|
713
1079
|
format: "note",
|
|
714
|
-
|
|
1080
|
+
bodyMarkdown: "root",
|
|
715
1081
|
status: "draft",
|
|
716
1082
|
});
|
|
717
1083
|
const reply = await postService.create({
|
|
718
1084
|
format: "note",
|
|
719
|
-
|
|
1085
|
+
bodyMarkdown: "reply",
|
|
1086
|
+
replyToId: root.id,
|
|
1087
|
+
});
|
|
1088
|
+
|
|
1089
|
+
expect(reply.status).toBe("draft");
|
|
1090
|
+
});
|
|
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",
|
|
720
1102
|
replyToId: root.id,
|
|
721
1103
|
});
|
|
722
1104
|
|
|
723
1105
|
expect(reply.status).toBe("draft");
|
|
1106
|
+
expect(reply.threadId).toBe(root.id);
|
|
1107
|
+
});
|
|
1108
|
+
|
|
1109
|
+
it("inherits visibility from root post", async () => {
|
|
1110
|
+
const root = await postService.create({
|
|
1111
|
+
format: "note",
|
|
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",
|
|
1129
|
+
});
|
|
1130
|
+
const reply = await postService.create({
|
|
1131
|
+
format: "note",
|
|
1132
|
+
bodyMarkdown: "reply",
|
|
1133
|
+
replyToId: root.id,
|
|
1134
|
+
});
|
|
1135
|
+
|
|
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");
|
|
724
1144
|
});
|
|
725
1145
|
|
|
726
|
-
it("
|
|
1146
|
+
it("does not inherit featuredAt from root post", async () => {
|
|
727
1147
|
const root = await postService.create({
|
|
728
1148
|
format: "note",
|
|
729
|
-
|
|
1149
|
+
bodyMarkdown: "root",
|
|
730
1150
|
featured: true,
|
|
731
1151
|
});
|
|
1152
|
+
|
|
1153
|
+
expect(root.featuredAt).toBeTypeOf("number");
|
|
1154
|
+
|
|
732
1155
|
const reply = await postService.create({
|
|
733
1156
|
format: "note",
|
|
734
|
-
|
|
1157
|
+
bodyMarkdown: "reply",
|
|
735
1158
|
replyToId: root.id,
|
|
736
1159
|
});
|
|
737
1160
|
|
|
738
|
-
|
|
1161
|
+
// featuredAt is an independent property — replies should NOT inherit it
|
|
1162
|
+
expect(reply.featuredAt).toBeNull();
|
|
739
1163
|
});
|
|
740
1164
|
|
|
741
1165
|
it("getThread returns all posts in a thread", async () => {
|
|
742
1166
|
const root = await postService.create({
|
|
743
1167
|
format: "note",
|
|
744
|
-
|
|
1168
|
+
bodyMarkdown: "root",
|
|
745
1169
|
});
|
|
746
1170
|
await postService.create({
|
|
747
1171
|
format: "note",
|
|
748
|
-
|
|
1172
|
+
bodyMarkdown: "reply1",
|
|
749
1173
|
replyToId: root.id,
|
|
750
1174
|
});
|
|
751
1175
|
await postService.create({
|
|
752
1176
|
format: "note",
|
|
753
|
-
|
|
1177
|
+
bodyMarkdown: "reply2",
|
|
754
1178
|
replyToId: root.id,
|
|
755
1179
|
});
|
|
756
1180
|
|
|
757
1181
|
const thread = await postService.getThread(root.id);
|
|
758
1182
|
expect(thread).toHaveLength(3);
|
|
759
1183
|
// Ordered by createdAt
|
|
760
|
-
expect(thread[0]?.
|
|
1184
|
+
expect(thread[0]?.bodyText).toBe("root");
|
|
761
1185
|
});
|
|
762
1186
|
|
|
763
1187
|
it("getThread excludes deleted posts", async () => {
|
|
764
1188
|
const root = await postService.create({
|
|
765
1189
|
format: "note",
|
|
766
|
-
|
|
1190
|
+
bodyMarkdown: "root",
|
|
767
1191
|
});
|
|
768
1192
|
const reply = await postService.create({
|
|
769
1193
|
format: "note",
|
|
770
|
-
|
|
1194
|
+
bodyMarkdown: "reply",
|
|
771
1195
|
replyToId: root.id,
|
|
772
1196
|
});
|
|
773
1197
|
|
|
@@ -780,12 +1204,12 @@ describe("PostService", () => {
|
|
|
780
1204
|
it("cascades status changes from root to thread", async () => {
|
|
781
1205
|
const root = await postService.create({
|
|
782
1206
|
format: "note",
|
|
783
|
-
|
|
1207
|
+
bodyMarkdown: "root",
|
|
784
1208
|
status: "published",
|
|
785
1209
|
});
|
|
786
1210
|
await postService.create({
|
|
787
1211
|
format: "note",
|
|
788
|
-
|
|
1212
|
+
bodyMarkdown: "reply",
|
|
789
1213
|
replyToId: root.id,
|
|
790
1214
|
});
|
|
791
1215
|
|
|
@@ -794,27 +1218,151 @@ describe("PostService", () => {
|
|
|
794
1218
|
const thread = await postService.getThread(root.id);
|
|
795
1219
|
for (const post of thread) {
|
|
796
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");
|
|
797
1244
|
}
|
|
798
1245
|
});
|
|
799
1246
|
|
|
800
|
-
it("cascades
|
|
1247
|
+
it("cascades visibility changes from root to thread", async () => {
|
|
801
1248
|
const root = await postService.create({
|
|
802
1249
|
format: "note",
|
|
803
|
-
|
|
1250
|
+
bodyMarkdown: "root",
|
|
804
1251
|
});
|
|
805
1252
|
await postService.create({
|
|
806
1253
|
format: "note",
|
|
807
|
-
|
|
1254
|
+
bodyMarkdown: "reply",
|
|
808
1255
|
replyToId: root.id,
|
|
809
1256
|
});
|
|
810
1257
|
|
|
811
|
-
await postService.update(root.id, {
|
|
1258
|
+
await postService.update(root.id, { visibility: "unlisted" });
|
|
812
1259
|
|
|
813
1260
|
const thread = await postService.getThread(root.id);
|
|
814
1261
|
for (const post of thread) {
|
|
815
|
-
expect(post.
|
|
1262
|
+
expect(post.visibility).toBe("unlisted");
|
|
816
1263
|
}
|
|
817
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
|
+
});
|
|
818
1366
|
});
|
|
819
1367
|
|
|
820
1368
|
describe("getReplyCounts", () => {
|
|
@@ -826,16 +1374,16 @@ describe("PostService", () => {
|
|
|
826
1374
|
it("returns reply counts for posts", async () => {
|
|
827
1375
|
const root = await postService.create({
|
|
828
1376
|
format: "note",
|
|
829
|
-
|
|
1377
|
+
bodyMarkdown: "root",
|
|
830
1378
|
});
|
|
831
1379
|
await postService.create({
|
|
832
1380
|
format: "note",
|
|
833
|
-
|
|
1381
|
+
bodyMarkdown: "reply1",
|
|
834
1382
|
replyToId: root.id,
|
|
835
1383
|
});
|
|
836
1384
|
await postService.create({
|
|
837
1385
|
format: "note",
|
|
838
|
-
|
|
1386
|
+
bodyMarkdown: "reply2",
|
|
839
1387
|
replyToId: root.id,
|
|
840
1388
|
});
|
|
841
1389
|
|
|
@@ -846,7 +1394,7 @@ describe("PostService", () => {
|
|
|
846
1394
|
it("returns 0 (missing) for posts without replies", async () => {
|
|
847
1395
|
const post = await postService.create({
|
|
848
1396
|
format: "note",
|
|
849
|
-
|
|
1397
|
+
bodyMarkdown: "no replies",
|
|
850
1398
|
});
|
|
851
1399
|
|
|
852
1400
|
const counts = await postService.getReplyCounts([post.id]);
|
|
@@ -856,16 +1404,16 @@ describe("PostService", () => {
|
|
|
856
1404
|
it("excludes deleted replies from count", async () => {
|
|
857
1405
|
const root = await postService.create({
|
|
858
1406
|
format: "note",
|
|
859
|
-
|
|
1407
|
+
bodyMarkdown: "root",
|
|
860
1408
|
});
|
|
861
1409
|
const reply = await postService.create({
|
|
862
1410
|
format: "note",
|
|
863
|
-
|
|
1411
|
+
bodyMarkdown: "reply",
|
|
864
1412
|
replyToId: root.id,
|
|
865
1413
|
});
|
|
866
1414
|
await postService.create({
|
|
867
1415
|
format: "note",
|
|
868
|
-
|
|
1416
|
+
bodyMarkdown: "reply2",
|
|
869
1417
|
replyToId: root.id,
|
|
870
1418
|
});
|
|
871
1419
|
|
|
@@ -874,100 +1422,122 @@ describe("PostService", () => {
|
|
|
874
1422
|
const counts = await postService.getReplyCounts([root.id]);
|
|
875
1423
|
expect(counts.get(root.id)).toBe(1);
|
|
876
1424
|
});
|
|
877
|
-
});
|
|
878
1425
|
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
const post = await postService.create({
|
|
1426
|
+
it("excludes draft replies from count", async () => {
|
|
1427
|
+
const root = await postService.create({
|
|
882
1428
|
format: "note",
|
|
883
|
-
|
|
884
|
-
path: "my-post",
|
|
1429
|
+
bodyMarkdown: "root",
|
|
885
1430
|
});
|
|
886
|
-
|
|
887
|
-
const entry = await pathRegistry.getByPath("my-post");
|
|
888
|
-
expect(entry).not.toBeNull();
|
|
889
|
-
expect(entry?.ownerType).toBe("post");
|
|
890
|
-
expect(entry?.ownerId).toBe(post.id);
|
|
891
|
-
});
|
|
892
|
-
|
|
893
|
-
it("does not claim when no path provided", async () => {
|
|
894
1431
|
await postService.create({
|
|
895
1432
|
format: "note",
|
|
896
|
-
|
|
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",
|
|
897
1441
|
});
|
|
898
1442
|
|
|
899
|
-
|
|
900
|
-
expect(
|
|
901
|
-
});
|
|
902
|
-
|
|
903
|
-
it("rejects path that conflicts with a page slug", async () => {
|
|
904
|
-
await pageService.create({ slug: "about", title: "About" });
|
|
905
|
-
|
|
906
|
-
await expect(
|
|
907
|
-
postService.create({ format: "note", body: "test", path: "about" }),
|
|
908
|
-
).rejects.toThrow(ConflictError);
|
|
909
|
-
});
|
|
910
|
-
|
|
911
|
-
it("rejects reserved path on create", async () => {
|
|
912
|
-
await expect(
|
|
913
|
-
postService.create({ format: "note", body: "test", path: "dash" }),
|
|
914
|
-
).rejects.toThrow(ValidationError);
|
|
1443
|
+
const counts = await postService.getReplyCounts([root.id]);
|
|
1444
|
+
expect(counts.get(root.id)).toBe(1);
|
|
915
1445
|
});
|
|
1446
|
+
});
|
|
916
1447
|
|
|
917
|
-
|
|
1448
|
+
describe("lastActivityAt (thread bump-to-top)", () => {
|
|
1449
|
+
it("sets lastActivityAt equal to publishedAt for non-thread posts", async () => {
|
|
918
1450
|
const post = await postService.create({
|
|
919
1451
|
format: "note",
|
|
920
|
-
|
|
921
|
-
|
|
1452
|
+
bodyMarkdown: "standalone",
|
|
1453
|
+
publishedAt: 5000,
|
|
922
1454
|
});
|
|
923
1455
|
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
expect(await pathRegistry.isAvailable("old-path")).toBe(true);
|
|
927
|
-
expect(await pathRegistry.isAvailable("new-path")).toBe(false);
|
|
1456
|
+
expect(post.lastActivityAt).toBe(5000);
|
|
928
1457
|
});
|
|
929
1458
|
|
|
930
|
-
it("
|
|
931
|
-
const
|
|
1459
|
+
it("updates root lastActivityAt when a reply is created", async () => {
|
|
1460
|
+
const root = await postService.create({
|
|
932
1461
|
format: "note",
|
|
933
|
-
|
|
934
|
-
|
|
1462
|
+
bodyMarkdown: "root",
|
|
1463
|
+
publishedAt: 1000,
|
|
935
1464
|
});
|
|
1465
|
+
expect(root.lastActivityAt).toBe(1000);
|
|
936
1466
|
|
|
937
|
-
await postService.
|
|
1467
|
+
await postService.create({
|
|
1468
|
+
format: "note",
|
|
1469
|
+
bodyMarkdown: "reply",
|
|
1470
|
+
replyToId: root.id,
|
|
1471
|
+
publishedAt: 9000,
|
|
1472
|
+
});
|
|
938
1473
|
|
|
939
|
-
|
|
1474
|
+
const updatedRoot = await postService.getById(root.id);
|
|
1475
|
+
expect(updatedRoot?.lastActivityAt).toBe(9000);
|
|
940
1476
|
});
|
|
941
1477
|
|
|
942
|
-
it("
|
|
943
|
-
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({
|
|
944
1485
|
format: "note",
|
|
945
|
-
|
|
946
|
-
|
|
1486
|
+
bodyMarkdown: "newer standalone",
|
|
1487
|
+
publishedAt: 5000,
|
|
947
1488
|
});
|
|
948
1489
|
|
|
949
|
-
|
|
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
|
+
});
|
|
950
1497
|
|
|
951
|
-
|
|
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");
|
|
952
1501
|
});
|
|
953
1502
|
|
|
954
|
-
it("
|
|
1503
|
+
it("recalculates root lastActivityAt when a reply is deleted", async () => {
|
|
955
1504
|
const root = await postService.create({
|
|
956
1505
|
format: "note",
|
|
957
|
-
|
|
958
|
-
|
|
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,
|
|
959
1514
|
});
|
|
960
1515
|
await postService.create({
|
|
961
1516
|
format: "note",
|
|
962
|
-
|
|
963
|
-
path: "thread-reply",
|
|
1517
|
+
bodyMarkdown: "reply2",
|
|
964
1518
|
replyToId: root.id,
|
|
1519
|
+
publishedAt: 5000,
|
|
965
1520
|
});
|
|
966
1521
|
|
|
967
|
-
|
|
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);
|
|
968
1538
|
|
|
969
|
-
|
|
970
|
-
expect(
|
|
1539
|
+
updatedRoot = await postService.getById(root.id);
|
|
1540
|
+
expect(updatedRoot?.lastActivityAt).toBe(1000);
|
|
971
1541
|
});
|
|
972
1542
|
});
|
|
973
1543
|
});
|