@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.
Files changed (307) hide show
  1. package/bin/commands/export.js +1 -1
  2. package/bin/commands/import-site.js +529 -0
  3. package/bin/commands/reset-password.js +3 -2
  4. package/dist/client/assets/heic-to-DIRPI3VF.js +1 -0
  5. package/dist/client/assets/module-RjUF93sV.js +716 -0
  6. package/dist/client/assets/native-48B9X9Wg.js +1 -0
  7. package/dist/client/assets/url-FWFqPJPb.js +1 -0
  8. package/dist/client/client.css +1 -1
  9. package/dist/client/client.js +4564 -3013
  10. package/dist/index.js +12885 -8161
  11. package/package.json +23 -6
  12. package/src/__tests__/helpers/app.ts +10 -10
  13. package/src/__tests__/helpers/db.ts +91 -87
  14. package/src/app.tsx +157 -31
  15. package/src/auth.ts +20 -2
  16. package/src/client/archive-nav.js +187 -0
  17. package/src/client/audio-player.ts +478 -0
  18. package/src/client/audio-processor.ts +84 -0
  19. package/src/{lib → client}/avatar-upload.ts +4 -3
  20. package/src/{lib → client}/collection-form-bridge.ts +2 -2
  21. package/src/{ui → client}/components/__tests__/jant-collection-form.test.ts +26 -9
  22. package/src/client/components/__tests__/jant-compose-dialog.test.ts +1140 -0
  23. package/src/client/components/__tests__/jant-compose-editor.test.ts +504 -0
  24. package/src/{ui → client}/components/__tests__/jant-post-form.test.ts +37 -17
  25. package/src/{ui → client}/components/__tests__/jant-settings-avatar.test.ts +2 -2
  26. package/src/{ui → client}/components/__tests__/jant-settings-general.test.ts +3 -3
  27. package/src/client/components/collection-sidebar-types.ts +43 -0
  28. package/src/{ui → client}/components/collection-types.ts +3 -4
  29. package/src/client/components/compose-types.ts +174 -0
  30. package/src/client/components/jant-collection-form.ts +667 -0
  31. package/src/client/components/jant-collection-sidebar.ts +805 -0
  32. package/src/client/components/jant-compose-dialog.ts +2161 -0
  33. package/src/client/components/jant-compose-editor.ts +1813 -0
  34. package/src/client/components/jant-compose-fullscreen.ts +283 -0
  35. package/src/client/components/jant-media-lightbox.ts +259 -0
  36. package/src/{ui → client}/components/jant-nav-manager.ts +97 -298
  37. package/src/{ui → client}/components/jant-post-form.ts +141 -12
  38. package/src/client/components/jant-post-menu.ts +1019 -0
  39. package/src/{ui → client}/components/jant-settings-avatar.ts +3 -3
  40. package/src/{ui → client}/components/jant-settings-general.ts +38 -4
  41. package/src/client/components/jant-text-preview.ts +232 -0
  42. package/src/{ui → client}/components/nav-manager-types.ts +6 -18
  43. package/src/{ui → client}/components/post-form-template.ts +137 -38
  44. package/src/{ui → client}/components/post-form-types.ts +15 -4
  45. package/src/client/compose-bridge.ts +583 -0
  46. package/src/{lib → client}/image-processor.ts +26 -8
  47. package/src/client/lazy-slugify.ts +51 -0
  48. package/src/client/media-metadata.ts +247 -0
  49. package/src/client/multipart-upload.ts +160 -0
  50. package/src/{lib → client}/nav-manager-bridge.ts +1 -1
  51. package/src/{lib → client}/post-form-bridge.ts +53 -2
  52. package/src/{lib → client}/settings-bridge.ts +3 -15
  53. package/src/client/thread-context.ts +140 -0
  54. package/src/client/tiptap/bubble-menu.ts +205 -0
  55. package/src/client/tiptap/create-editor.ts +86 -0
  56. package/src/client/tiptap/exitable-marks.ts +73 -0
  57. package/src/client/tiptap/extensions.ts +65 -0
  58. package/src/client/tiptap/image-node.ts +482 -0
  59. package/src/client/tiptap/link-toolbar.ts +371 -0
  60. package/src/client/tiptap/more-break.ts +50 -0
  61. package/src/client/tiptap/paste-image.ts +129 -0
  62. package/src/client/tiptap/slash-commands.ts +438 -0
  63. package/src/{lib → client}/toast.ts +101 -3
  64. package/src/client/types/sortablejs.d.ts +44 -0
  65. package/src/client/upload-with-metadata.ts +54 -0
  66. package/src/client/video-processor.ts +207 -0
  67. package/src/client.ts +27 -17
  68. package/src/db/__tests__/migrations.test.ts +118 -0
  69. package/src/db/index.ts +52 -0
  70. package/src/db/migrations/0000_baseline.sql +269 -0
  71. package/src/db/migrations/0001_fts_setup.sql +31 -0
  72. package/src/db/migrations/meta/0000_snapshot.json +703 -119
  73. package/src/db/migrations/meta/0001_snapshot.json +1337 -0
  74. package/src/db/migrations/meta/_journal.json +4 -39
  75. package/src/db/schema.ts +409 -140
  76. package/src/i18n/__tests__/detect.test.ts +115 -0
  77. package/src/i18n/context.tsx +2 -2
  78. package/src/i18n/detect.ts +85 -1
  79. package/src/i18n/i18n.ts +1 -1
  80. package/src/i18n/index.ts +2 -1
  81. package/src/i18n/locales/en.po +783 -1087
  82. package/src/i18n/locales/en.ts +1 -1
  83. package/src/i18n/locales/zh-Hans.po +867 -812
  84. package/src/i18n/locales/zh-Hans.ts +1 -1
  85. package/src/i18n/locales/zh-Hant.po +878 -823
  86. package/src/i18n/locales/zh-Hant.ts +1 -1
  87. package/src/i18n/middleware.ts +6 -0
  88. package/src/index.ts +5 -7
  89. package/src/lib/__tests__/blurhash-placeholder.test.ts +75 -0
  90. package/src/lib/__tests__/constants.test.ts +0 -1
  91. package/src/lib/__tests__/markdown-to-tiptap.test.ts +358 -0
  92. package/src/lib/__tests__/nanoid.test.ts +26 -0
  93. package/src/lib/__tests__/resolve-config.test.ts +2 -2
  94. package/src/lib/__tests__/schemas.test.ts +186 -65
  95. package/src/lib/__tests__/slug.test.ts +126 -0
  96. package/src/lib/__tests__/sse.test.ts +6 -6
  97. package/src/lib/__tests__/summary.test.ts +264 -0
  98. package/src/lib/__tests__/theme.test.ts +1 -1
  99. package/src/lib/__tests__/timeline.test.ts +33 -30
  100. package/src/lib/__tests__/tiptap-to-markdown.test.ts +346 -0
  101. package/src/lib/__tests__/url.test.ts +2 -2
  102. package/src/lib/__tests__/view.test.ts +140 -65
  103. package/src/lib/blurhash-placeholder.ts +102 -0
  104. package/src/lib/constants.ts +3 -1
  105. package/src/lib/emoji-catalog.ts +963 -0
  106. package/src/lib/errors.ts +11 -8
  107. package/src/lib/feed.ts +77 -31
  108. package/src/lib/html.ts +2 -1
  109. package/src/lib/icon-catalog.ts +5033 -1
  110. package/src/lib/icons.ts +3 -2
  111. package/src/lib/index.ts +0 -1
  112. package/src/lib/markdown-to-tiptap.ts +286 -0
  113. package/src/lib/media-helpers.ts +22 -12
  114. package/src/lib/nanoid.ts +29 -0
  115. package/src/lib/navigation.ts +1 -1
  116. package/src/lib/render.tsx +24 -5
  117. package/src/lib/resolve-config.ts +13 -2
  118. package/src/lib/schemas.ts +226 -58
  119. package/src/lib/search-snippet.ts +34 -0
  120. package/src/lib/slug.ts +96 -0
  121. package/src/lib/sse.ts +6 -6
  122. package/src/lib/storage.ts +115 -7
  123. package/src/lib/summary.ts +158 -0
  124. package/src/lib/theme.ts +11 -8
  125. package/src/lib/timeline.ts +76 -34
  126. package/src/lib/tiptap-render.ts +191 -0
  127. package/src/lib/tiptap-to-markdown.ts +305 -0
  128. package/src/lib/upload.ts +263 -14
  129. package/src/lib/url.ts +37 -22
  130. package/src/lib/view.ts +236 -55
  131. package/src/middleware/__tests__/auth.test.ts +191 -11
  132. package/src/middleware/__tests__/onboarding.test.ts +12 -10
  133. package/src/middleware/auth.ts +63 -9
  134. package/src/middleware/error-handler.ts +3 -3
  135. package/src/middleware/onboarding.ts +1 -1
  136. package/src/middleware/secure-headers.ts +40 -0
  137. package/src/preset.css +83 -2
  138. package/src/routes/__tests__/compose.test.ts +17 -24
  139. package/src/routes/api/__tests__/collections.test.ts +109 -61
  140. package/src/routes/api/__tests__/nav-items.test.ts +46 -29
  141. package/src/routes/api/__tests__/posts.test.ts +132 -68
  142. package/src/routes/api/__tests__/search.test.ts +15 -2
  143. package/src/routes/api/__tests__/upload-multipart.test.ts +534 -0
  144. package/src/routes/api/collections.ts +57 -31
  145. package/src/routes/api/custom-urls.ts +80 -0
  146. package/src/routes/api/export.ts +31 -0
  147. package/src/routes/api/nav-items.ts +23 -19
  148. package/src/routes/api/posts.ts +81 -62
  149. package/src/routes/api/search.ts +3 -4
  150. package/src/routes/api/upload-multipart.ts +245 -0
  151. package/src/routes/api/upload.ts +92 -24
  152. package/src/routes/auth/__tests__/setup.test.ts +20 -60
  153. package/src/routes/auth/reset.tsx +5 -4
  154. package/src/routes/auth/setup.tsx +39 -31
  155. package/src/routes/auth/signin.tsx +13 -14
  156. package/src/routes/compose.tsx +27 -63
  157. package/src/routes/dash/__tests__/settings-avatar.test.ts +44 -9
  158. package/src/routes/dash/custom-urls.tsx +414 -0
  159. package/src/routes/dash/settings.tsx +475 -99
  160. package/src/routes/feed/__tests__/rss.test.ts +22 -23
  161. package/src/routes/feed/rss.ts +6 -2
  162. package/src/routes/feed/sitemap.ts +2 -12
  163. package/src/routes/pages/__tests__/collections.test.ts +5 -6
  164. package/src/routes/pages/__tests__/featured.test.ts +36 -18
  165. package/src/routes/pages/archive.tsx +177 -37
  166. package/src/routes/pages/collection.tsx +43 -14
  167. package/src/routes/pages/collections.tsx +11 -2
  168. package/src/routes/pages/featured.tsx +27 -3
  169. package/src/routes/pages/home.tsx +15 -14
  170. package/src/routes/pages/latest.tsx +1 -11
  171. package/src/routes/pages/new.tsx +39 -0
  172. package/src/routes/pages/page.tsx +95 -49
  173. package/src/routes/pages/search.tsx +1 -1
  174. package/src/services/__tests__/api-token.test.ts +135 -0
  175. package/src/services/__tests__/collection.test.ts +275 -227
  176. package/src/services/__tests__/custom-url.test.ts +213 -0
  177. package/src/services/__tests__/media.test.ts +162 -22
  178. package/src/services/__tests__/navigation.test.ts +109 -68
  179. package/src/services/__tests__/post-timeline.test.ts +205 -32
  180. package/src/services/__tests__/post.test.ts +800 -230
  181. package/src/services/__tests__/search.test.ts +67 -10
  182. package/src/services/__tests__/settings.test.ts +3 -3
  183. package/src/services/api-token.ts +166 -0
  184. package/src/services/auth.ts +17 -2
  185. package/src/services/collection.ts +397 -131
  186. package/src/services/custom-url.ts +188 -0
  187. package/src/services/export.ts +802 -0
  188. package/src/services/index.ts +26 -19
  189. package/src/services/media.ts +100 -22
  190. package/src/services/navigation.ts +158 -47
  191. package/src/services/path.ts +339 -0
  192. package/src/services/post.ts +764 -172
  193. package/src/services/search.ts +161 -74
  194. package/src/services/settings.ts +6 -2
  195. package/src/styles/components.css +293 -62
  196. package/src/styles/tokens.css +93 -5
  197. package/src/styles/ui.css +4349 -766
  198. package/src/types/bindings.ts +8 -0
  199. package/src/types/config.ts +34 -4
  200. package/src/types/constants.ts +17 -2
  201. package/src/types/entities.ts +83 -37
  202. package/src/types/operations.ts +20 -27
  203. package/src/types/props.ts +52 -17
  204. package/src/types/views.ts +48 -24
  205. package/src/ui/color-themes.ts +133 -23
  206. package/src/ui/compose/ComposeDialog.tsx +255 -16
  207. package/src/ui/compose/ComposePrompt.tsx +1 -1
  208. package/src/ui/dash/CrudPageHeader.tsx +1 -1
  209. package/src/ui/dash/ListItemRow.tsx +1 -1
  210. package/src/ui/dash/StatusBadge.tsx +12 -2
  211. package/src/ui/dash/appearance/AdvancedContent.tsx +71 -59
  212. package/src/ui/dash/appearance/ColorThemeContent.tsx +48 -33
  213. package/src/ui/dash/appearance/FontThemeContent.tsx +65 -73
  214. package/src/ui/dash/appearance/NavigationContent.tsx +106 -135
  215. package/src/ui/dash/index.ts +0 -3
  216. package/src/ui/dash/settings/AccountContent.tsx +87 -146
  217. package/src/ui/dash/settings/AccountMenuContent.tsx +147 -0
  218. package/src/ui/dash/settings/ApiTokensContent.tsx +232 -0
  219. package/src/ui/dash/settings/AvatarContent.tsx +78 -0
  220. package/src/ui/dash/settings/GeneralContent.tsx +3 -62
  221. package/src/ui/dash/settings/SessionsContent.tsx +159 -0
  222. package/src/ui/dash/settings/SettingsRootContent.tsx +266 -0
  223. package/src/ui/feed/LinkCard.tsx +89 -40
  224. package/src/ui/feed/NoteCard.tsx +39 -25
  225. package/src/ui/feed/PostStatusBadges.tsx +67 -0
  226. package/src/ui/feed/QuoteCard.tsx +38 -23
  227. package/src/ui/feed/ThreadPreview.tsx +90 -26
  228. package/src/ui/feed/TimelineFeed.tsx +3 -2
  229. package/src/ui/feed/TimelineItem.tsx +15 -6
  230. package/src/ui/feed/__tests__/thread-preview.test.ts +107 -0
  231. package/src/ui/feed/thread-preview-state.ts +61 -0
  232. package/src/ui/font-themes.ts +2 -2
  233. package/src/ui/layouts/BaseLayout.tsx +2 -2
  234. package/src/ui/layouts/SiteLayout.tsx +116 -103
  235. package/src/ui/pages/ArchivePage.tsx +923 -95
  236. package/src/ui/pages/CollectionPage.tsx +6 -35
  237. package/src/ui/pages/CollectionsPage.tsx +2 -1
  238. package/src/ui/pages/ComposePage.tsx +54 -0
  239. package/src/ui/pages/FeaturedPage.tsx +2 -1
  240. package/src/ui/pages/HomePage.tsx +1 -1
  241. package/src/ui/pages/PostPage.tsx +30 -45
  242. package/src/ui/pages/SearchPage.tsx +182 -38
  243. package/src/ui/shared/AdminBreadcrumb.tsx +29 -0
  244. package/src/ui/shared/CollectionsSidebar.tsx +239 -4
  245. package/src/ui/shared/MediaGallery.tsx +475 -41
  246. package/src/ui/shared/PostFooter.tsx +204 -0
  247. package/src/ui/shared/StarRating.tsx +27 -0
  248. package/src/ui/shared/__tests__/format-chars.test.ts +35 -0
  249. package/src/ui/shared/index.ts +0 -1
  250. package/src/db/migrations/0000_square_wallflower.sql +0 -118
  251. package/src/db/migrations/0001_add_search_fts.sql +0 -34
  252. package/src/db/migrations/0002_add_media_attachments.sql +0 -3
  253. package/src/db/migrations/0003_add_navigation_links.sql +0 -8
  254. package/src/db/migrations/0004_add_storage_provider.sql +0 -3
  255. package/src/db/migrations/0005_v2_schema_migration.sql +0 -268
  256. package/src/db/migrations/0006_rename_slug_to_path.sql +0 -5
  257. package/src/db/migrations/0007_post_collections_m2m.sql +0 -94
  258. package/src/db/migrations/0008_add_collection_dividers.sql +0 -8
  259. package/src/db/migrations/0009_drop_collection_show_divider.sql +0 -2
  260. package/src/db/migrations/0010_add_performance_indexes.sql +0 -16
  261. package/src/db/migrations/0011_add_path_registry.sql +0 -23
  262. package/src/db/migrations/meta/0003_snapshot.json +0 -821
  263. package/src/lib/__tests__/sqid.test.ts +0 -65
  264. package/src/lib/collections-reorder.ts +0 -28
  265. package/src/lib/compose-bridge.ts +0 -280
  266. package/src/lib/media-upload.ts +0 -148
  267. package/src/lib/sqid.ts +0 -79
  268. package/src/routes/api/__tests__/pages.test.ts +0 -218
  269. package/src/routes/api/pages.ts +0 -73
  270. package/src/routes/dash/__tests__/pages.test.ts +0 -226
  271. package/src/routes/dash/appearance.tsx +0 -240
  272. package/src/routes/dash/collections.tsx +0 -211
  273. package/src/routes/dash/index.tsx +0 -103
  274. package/src/routes/dash/media.tsx +0 -132
  275. package/src/routes/dash/pages.tsx +0 -239
  276. package/src/routes/dash/posts.tsx +0 -334
  277. package/src/routes/dash/redirects.tsx +0 -257
  278. package/src/routes/pages/post.tsx +0 -59
  279. package/src/services/__tests__/page.test.ts +0 -298
  280. package/src/services/__tests__/path-registry.test.ts +0 -165
  281. package/src/services/__tests__/redirect.test.ts +0 -159
  282. package/src/services/page.ts +0 -203
  283. package/src/services/path-registry.ts +0 -160
  284. package/src/services/redirect.ts +0 -97
  285. package/src/types/sortablejs.d.ts +0 -29
  286. package/src/ui/components/__tests__/jant-compose-dialog.test.ts +0 -512
  287. package/src/ui/components/__tests__/jant-compose-editor.test.ts +0 -272
  288. package/src/ui/components/compose-types.ts +0 -75
  289. package/src/ui/components/jant-collection-form.ts +0 -512
  290. package/src/ui/components/jant-compose-dialog.ts +0 -495
  291. package/src/ui/components/jant-compose-editor.ts +0 -814
  292. package/src/ui/dash/PageForm.tsx +0 -185
  293. package/src/ui/dash/PostList.tsx +0 -95
  294. package/src/ui/dash/appearance/AppearanceNav.tsx +0 -60
  295. package/src/ui/dash/collections/CollectionForm.tsx +0 -166
  296. package/src/ui/dash/collections/CollectionsListContent.tsx +0 -146
  297. package/src/ui/dash/collections/IconPickerGrid.tsx +0 -50
  298. package/src/ui/dash/collections/ViewCollectionContent.tsx +0 -103
  299. package/src/ui/dash/media/MediaListContent.tsx +0 -201
  300. package/src/ui/dash/media/ViewMediaContent.tsx +0 -208
  301. package/src/ui/dash/pages/PagesContent.tsx +0 -74
  302. package/src/ui/dash/posts/PostForm.tsx +0 -248
  303. package/src/ui/dash/settings/SettingsNav.tsx +0 -52
  304. package/src/ui/layouts/DashLayout.tsx +0 -165
  305. package/src/ui/pages/SinglePage.tsx +0 -23
  306. package/src/ui/shared/ThreadView.tsx +0 -136
  307. /package/src/{ui → client}/components/settings-types.ts +0 -0
@@ -0,0 +1,213 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { createTestDatabase } from "../../__tests__/helpers/db.js";
3
+ import { createCustomUrlService } from "../custom-url.js";
4
+ import { createPostService } from "../post.js";
5
+ import type { Database } from "../../db/index.js";
6
+
7
+ describe("CustomUrlService", () => {
8
+ let db: Database;
9
+ let customUrlService: ReturnType<typeof createCustomUrlService>;
10
+ let postService: ReturnType<typeof createPostService>;
11
+
12
+ beforeEach(() => {
13
+ const testDb = createTestDatabase();
14
+ db = testDb.db as unknown as Database;
15
+ customUrlService = createCustomUrlService(db);
16
+ postService = createPostService(db, { slugIdLength: 5 });
17
+ });
18
+
19
+ describe("create", () => {
20
+ it("creates a redirect custom URL", async () => {
21
+ const url = await customUrlService.create({
22
+ path: "old-page",
23
+ targetType: "redirect",
24
+ toPath: "/new-page",
25
+ redirectType: 301,
26
+ });
27
+
28
+ expect(url.path).toBe("old-page");
29
+ expect(url.targetType).toBe("redirect");
30
+ expect(url.toPath).toBe("/new-page");
31
+ expect(url.redirectType).toBe(301);
32
+ expect(typeof url.id).toBe("string");
33
+ expect(typeof url.createdAt).toBe("number");
34
+ });
35
+
36
+ it("creates a post custom URL", async () => {
37
+ const post = await postService.create({ format: "note" });
38
+
39
+ const url = await customUrlService.create({
40
+ path: "blog/my-post",
41
+ targetType: "post",
42
+ targetId: post.id,
43
+ });
44
+
45
+ expect(url.path).toBe("blog/my-post");
46
+ expect(url.targetType).toBe("post");
47
+ expect(url.targetId).toBe(post.id);
48
+ });
49
+
50
+ it("rejects reserved paths", async () => {
51
+ await expect(
52
+ customUrlService.create({
53
+ path: "dash",
54
+ targetType: "redirect",
55
+ toPath: "/somewhere",
56
+ }),
57
+ ).rejects.toThrow("reserved");
58
+ });
59
+
60
+ it("rejects duplicate paths", async () => {
61
+ await customUrlService.create({
62
+ path: "my-path",
63
+ targetType: "redirect",
64
+ toPath: "/target",
65
+ });
66
+
67
+ await expect(
68
+ customUrlService.create({
69
+ path: "my-path",
70
+ targetType: "redirect",
71
+ toPath: "/other-target",
72
+ }),
73
+ ).rejects.toThrow("already in use");
74
+ });
75
+
76
+ it("rejects paths that conflict with post slugs", async () => {
77
+ const post = await postService.create({
78
+ format: "note",
79
+ slug: "my-slug",
80
+ });
81
+
82
+ await expect(
83
+ customUrlService.create({
84
+ path: post.slug,
85
+ targetType: "redirect",
86
+ toPath: "/somewhere",
87
+ }),
88
+ ).rejects.toThrow("conflicts with an existing post slug");
89
+ });
90
+ });
91
+
92
+ describe("getByPath", () => {
93
+ it("returns custom URL by path", async () => {
94
+ await customUrlService.create({
95
+ path: "test-path",
96
+ targetType: "redirect",
97
+ toPath: "/target",
98
+ redirectType: 302,
99
+ });
100
+
101
+ const result = await customUrlService.getByPath("test-path");
102
+ expect(result).not.toBeNull();
103
+ expect(result?.path).toBe("test-path");
104
+ expect(result?.redirectType).toBe(302);
105
+ });
106
+
107
+ it("returns null for non-existent path", async () => {
108
+ const result = await customUrlService.getByPath("nonexistent");
109
+ expect(result).toBeNull();
110
+ });
111
+ });
112
+
113
+ describe("getByTarget", () => {
114
+ it("returns custom URL by target", async () => {
115
+ const post = await postService.create({ format: "note" });
116
+ await customUrlService.create({
117
+ path: "custom-path",
118
+ targetType: "post",
119
+ targetId: post.id,
120
+ });
121
+
122
+ const result = await customUrlService.getByTarget("post", post.id);
123
+ expect(result).not.toBeNull();
124
+ expect(result?.path).toBe("custom-path");
125
+ expect(result?.targetId).toBe(post.id);
126
+ });
127
+
128
+ it("returns null when no match", async () => {
129
+ const result = await customUrlService.getByTarget(
130
+ "post",
131
+ "nonexistent-id",
132
+ );
133
+ expect(result).toBeNull();
134
+ });
135
+ });
136
+
137
+ describe("delete", () => {
138
+ it("deletes a custom URL", async () => {
139
+ const url = await customUrlService.create({
140
+ path: "to-delete",
141
+ targetType: "redirect",
142
+ toPath: "/target",
143
+ });
144
+
145
+ const deleted = await customUrlService.delete(url.id);
146
+ expect(deleted).toBe(true);
147
+
148
+ const result = await customUrlService.getByPath("to-delete");
149
+ expect(result).toBeNull();
150
+ });
151
+
152
+ it("returns false for non-existent ID", async () => {
153
+ const deleted = await customUrlService.delete("nonexistent-id");
154
+ expect(deleted).toBe(false);
155
+ });
156
+ });
157
+
158
+ describe("list", () => {
159
+ it("returns all custom URLs", async () => {
160
+ await customUrlService.create({
161
+ path: "path-1",
162
+ targetType: "redirect",
163
+ toPath: "/a",
164
+ });
165
+ await customUrlService.create({
166
+ path: "path-2",
167
+ targetType: "redirect",
168
+ toPath: "/b",
169
+ });
170
+
171
+ const all = await customUrlService.list();
172
+ expect(all).toHaveLength(2);
173
+ });
174
+
175
+ it("returns empty array when none exist", async () => {
176
+ const all = await customUrlService.list();
177
+ expect(all).toEqual([]);
178
+ });
179
+ });
180
+
181
+ describe("isPathAvailable", () => {
182
+ it("returns true for available path", async () => {
183
+ const available = await customUrlService.isPathAvailable("free-path");
184
+ expect(available).toBe(true);
185
+ });
186
+
187
+ it("returns false for reserved path", async () => {
188
+ const available = await customUrlService.isPathAvailable("api");
189
+ expect(available).toBe(false);
190
+ });
191
+
192
+ it("returns false for existing custom URL path", async () => {
193
+ await customUrlService.create({
194
+ path: "taken-path",
195
+ targetType: "redirect",
196
+ toPath: "/target",
197
+ });
198
+
199
+ const available = await customUrlService.isPathAvailable("taken-path");
200
+ expect(available).toBe(false);
201
+ });
202
+
203
+ it("returns false for existing post slug", async () => {
204
+ const post = await postService.create({
205
+ format: "note",
206
+ slug: "post-slug",
207
+ });
208
+
209
+ const available = await customUrlService.isPathAvailable(post.slug);
210
+ expect(available).toBe(false);
211
+ });
212
+ });
213
+ });
@@ -3,7 +3,6 @@ import { describe, it, expect, beforeEach } from "vitest";
3
3
  import { createTestDatabase } from "../../__tests__/helpers/db.js";
4
4
  import { createMediaService } from "../media.js";
5
5
  import { createPostService } from "../post.js";
6
- import { createPathRegistryService } from "../path-registry.js";
7
6
  import type { Database } from "../../db/index.js";
8
7
 
9
8
  describe("MediaService", () => {
@@ -15,7 +14,7 @@ describe("MediaService", () => {
15
14
  const testDb = createTestDatabase();
16
15
  db = testDb.db as unknown as Database;
17
16
  mediaService = createMediaService(db);
18
- postService = createPostService(db, createPathRegistryService(db));
17
+ postService = createPostService(db, { slugIdLength: 5 });
19
18
  });
20
19
 
21
20
  const sampleMedia = {
@@ -45,8 +44,19 @@ describe("MediaService", () => {
45
44
  expect(media.height).toBe(1080);
46
45
  expect(media.postId).toBeNull();
47
46
  expect(media.alt).toBeNull();
48
- expect(media.position).toBe(0);
47
+ expect(media.position).toBe("a0");
49
48
  expect(media.blurhash).toBeNull();
49
+ expect(media.posterKey).toBeNull();
50
+ });
51
+
52
+ it("creates media with posterKey", async () => {
53
+ const media = await mediaService.create({
54
+ ...sampleMedia,
55
+ storageKey: "media/2025/01/video.mp4",
56
+ posterKey: "media/2025/01/video-poster.webp",
57
+ });
58
+
59
+ expect(media.posterKey).toBe("media/2025/01/video-poster.webp");
50
60
  });
51
61
 
52
62
  it("creates media with optional alt text", async () => {
@@ -75,14 +85,34 @@ describe("MediaService", () => {
75
85
  it("creates media with position and blurhash", async () => {
76
86
  const media = await mediaService.create({
77
87
  ...sampleMedia,
78
- position: 3,
88
+ position: "a3",
79
89
  blurhash: "LKO2?U%2Tw=w]~RBVZRi};RPxuwH",
80
90
  });
81
91
 
82
- expect(media.position).toBe(3);
92
+ expect(media.position).toBe("a3");
83
93
  expect(media.blurhash).toBe("LKO2?U%2Tw=w]~RBVZRi};RPxuwH");
84
94
  });
85
95
 
96
+ it("appends position when creating media already attached to a post", async () => {
97
+ const post = await postService.create({
98
+ format: "note",
99
+ bodyMarkdown: "test",
100
+ });
101
+
102
+ const media1 = await mediaService.create({
103
+ ...sampleMedia,
104
+ postId: post.id,
105
+ });
106
+ const media2 = await mediaService.create({
107
+ ...sampleMedia,
108
+ postId: post.id,
109
+ storageKey: "media/2025/01/second.jpg",
110
+ });
111
+
112
+ expect(media1.position).toBe("a0");
113
+ expect(media2.position).toBe("a1");
114
+ });
115
+
86
116
  it("generates UUIDv7 IDs", async () => {
87
117
  const media1 = await mediaService.create(sampleMedia);
88
118
  const media2 = await mediaService.create({
@@ -117,6 +147,46 @@ describe("MediaService", () => {
117
147
  /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
118
148
  );
119
149
  });
150
+
151
+ it("rejects non-positive sizes at the database layer", async () => {
152
+ await expect(
153
+ mediaService.create({
154
+ ...sampleMedia,
155
+ storageKey: "media/2025/01/invalid-size.jpg",
156
+ size: 0,
157
+ }),
158
+ ).rejects.toThrow();
159
+ });
160
+
161
+ it("rejects blank positions at the database layer", async () => {
162
+ await expect(
163
+ mediaService.create({
164
+ ...sampleMedia,
165
+ storageKey: "media/2025/01/invalid-position.jpg",
166
+ position: " ",
167
+ }),
168
+ ).rejects.toThrow();
169
+ });
170
+
171
+ it("rejects non-positive dimensions at the database layer", async () => {
172
+ await expect(
173
+ mediaService.create({
174
+ ...sampleMedia,
175
+ storageKey: "media/2025/01/invalid-dimensions.jpg",
176
+ width: 0,
177
+ }),
178
+ ).rejects.toThrow();
179
+ });
180
+
181
+ it("rejects negative extracted text length at the database layer", async () => {
182
+ await expect(
183
+ mediaService.create({
184
+ ...sampleMedia,
185
+ storageKey: "media/2025/01/invalid-chars.jpg",
186
+ chars: -1,
187
+ }),
188
+ ).rejects.toThrow();
189
+ });
120
190
  });
121
191
 
122
192
  describe("getById", () => {
@@ -168,7 +238,7 @@ describe("MediaService", () => {
168
238
  it("returns media ordered by position", async () => {
169
239
  const post = await postService.create({
170
240
  format: "note",
171
- body: "test",
241
+ bodyMarkdown: "test",
172
242
  });
173
243
 
174
244
  const m1 = await mediaService.create({
@@ -185,15 +255,15 @@ describe("MediaService", () => {
185
255
  const results = await mediaService.getByPostId(post.id);
186
256
  expect(results).toHaveLength(2);
187
257
  expect(results[0]!.id).toBe(m2.id);
188
- expect(results[0]!.position).toBe(0);
258
+ expect(results[0]!.position).toBe("a0");
189
259
  expect(results[1]!.id).toBe(m1.id);
190
- expect(results[1]!.position).toBe(1);
260
+ expect(results[1]!.position).toBe("a1");
191
261
  });
192
262
 
193
263
  it("returns empty array for post with no media", async () => {
194
264
  const post = await postService.create({
195
265
  format: "note",
196
- body: "test",
266
+ bodyMarkdown: "test",
197
267
  });
198
268
 
199
269
  const results = await mediaService.getByPostId(post.id);
@@ -205,11 +275,11 @@ describe("MediaService", () => {
205
275
  it("returns Map grouped by postId", async () => {
206
276
  const post1 = await postService.create({
207
277
  format: "note",
208
- body: "post 1",
278
+ bodyMarkdown: "post 1",
209
279
  });
210
280
  const post2 = await postService.create({
211
281
  format: "note",
212
- body: "post 2",
282
+ bodyMarkdown: "post 2",
213
283
  });
214
284
 
215
285
  const m1 = await mediaService.create({
@@ -242,7 +312,7 @@ describe("MediaService", () => {
242
312
  it("returns ordered by position within each post", async () => {
243
313
  const post = await postService.create({
244
314
  format: "note",
245
- body: "test",
315
+ bodyMarkdown: "test",
246
316
  });
247
317
 
248
318
  const m1 = await mediaService.create({
@@ -314,15 +384,37 @@ describe("MediaService", () => {
314
384
 
315
385
  const found = await mediaService.getByStorageKey(
316
386
  "media/2025/01/0192a9f1-a2b7-7c3d-8e4f-5a6b7c8d9e0f.jpg",
387
+ "r2",
317
388
  );
318
389
  expect(found).not.toBeNull();
319
390
  expect(found?.originalName).toBe("photo.jpg");
320
391
  });
321
392
 
322
393
  it("returns null for non-existent R2 key", async () => {
323
- const found = await mediaService.getByStorageKey("nonexistent");
394
+ const found = await mediaService.getByStorageKey("nonexistent", "r2");
324
395
  expect(found).toBeNull();
325
396
  });
397
+
398
+ it("allows the same storage key on different providers", async () => {
399
+ await mediaService.create(sampleMedia);
400
+ await mediaService.create({
401
+ ...sampleMedia,
402
+ id: "0192a9f1-a2b7-7c3d-8e4f-5a6b7c8d9e10",
403
+ provider: "s3",
404
+ });
405
+
406
+ const r2Media = await mediaService.getByStorageKey(
407
+ sampleMedia.storageKey,
408
+ "r2",
409
+ );
410
+ const s3Media = await mediaService.getByStorageKey(
411
+ sampleMedia.storageKey,
412
+ "s3",
413
+ );
414
+
415
+ expect(r2Media?.provider).toBe("r2");
416
+ expect(s3Media?.provider).toBe("s3");
417
+ });
326
418
  });
327
419
 
328
420
  describe("list", () => {
@@ -356,7 +448,7 @@ describe("MediaService", () => {
356
448
  it("sets postId and position for each media", async () => {
357
449
  const post = await postService.create({
358
450
  format: "note",
359
- body: "test",
451
+ bodyMarkdown: "test",
360
452
  });
361
453
 
362
454
  const m1 = await mediaService.create({
@@ -373,15 +465,15 @@ describe("MediaService", () => {
373
465
  const attached = await mediaService.getByPostId(post.id);
374
466
  expect(attached).toHaveLength(2);
375
467
  expect(attached[0]!.id).toBe(m1.id);
376
- expect(attached[0]!.position).toBe(0);
468
+ expect(attached[0]!.position).toBe("a0");
377
469
  expect(attached[1]!.id).toBe(m2.id);
378
- expect(attached[1]!.position).toBe(1);
470
+ expect(attached[1]!.position).toBe("a1");
379
471
  });
380
472
 
381
473
  it("replaces existing attachments", async () => {
382
474
  const post = await postService.create({
383
475
  format: "note",
384
- body: "test",
476
+ bodyMarkdown: "test",
385
477
  });
386
478
 
387
479
  const m1 = await mediaService.create({
@@ -403,18 +495,18 @@ describe("MediaService", () => {
403
495
  const attached = await mediaService.getByPostId(post.id);
404
496
  expect(attached).toHaveLength(1);
405
497
  expect(attached[0]!.id).toBe(m3.id);
406
- expect(attached[0]!.position).toBe(0);
498
+ expect(attached[0]!.position).toBe("a0");
407
499
 
408
500
  // Verify old media is detached
409
501
  const old1 = await mediaService.getById(m1.id);
410
502
  expect(old1!.postId).toBeNull();
411
- expect(old1!.position).toBe(0);
503
+ expect(old1!.position).toBe("a0");
412
504
  });
413
505
 
414
506
  it("handles empty array by clearing all attachments", async () => {
415
507
  const post = await postService.create({
416
508
  format: "note",
417
- body: "test",
509
+ bodyMarkdown: "test",
418
510
  });
419
511
 
420
512
  const m1 = await mediaService.create({
@@ -434,7 +526,7 @@ describe("MediaService", () => {
434
526
  it("clears postId and resets position", async () => {
435
527
  const post = await postService.create({
436
528
  format: "note",
437
- body: "test",
529
+ bodyMarkdown: "test",
438
530
  });
439
531
 
440
532
  const m1 = await mediaService.create({
@@ -450,7 +542,7 @@ describe("MediaService", () => {
450
542
 
451
543
  const detached = await mediaService.getById(m1.id);
452
544
  expect(detached!.postId).toBeNull();
453
- expect(detached!.position).toBe(0);
545
+ expect(detached!.position).toBe("a0");
454
546
  });
455
547
  });
456
548
 
@@ -469,6 +561,27 @@ describe("MediaService", () => {
469
561
  const result = await mediaService.delete("nonexistent");
470
562
  expect(result).toBe(false);
471
563
  });
564
+
565
+ it("deletes poster from storage when posterKey exists", async () => {
566
+ const media = await mediaService.create({
567
+ ...sampleMedia,
568
+ storageKey: "media/2025/01/vid.mp4",
569
+ posterKey: "media/2025/01/vid-poster.webp",
570
+ });
571
+
572
+ const deletedKeys: string[] = [];
573
+ const mockStorage = {
574
+ delete: async (key: string) => {
575
+ deletedKeys.push(key);
576
+ },
577
+ put: async () => {},
578
+ get: async () => null,
579
+ };
580
+
581
+ await mediaService.delete(media.id, mockStorage as never);
582
+ expect(deletedKeys).toContain("media/2025/01/vid.mp4");
583
+ expect(deletedKeys).toContain("media/2025/01/vid-poster.webp");
584
+ });
472
585
  });
473
586
 
474
587
  describe("deleteByIds", () => {
@@ -500,5 +613,32 @@ describe("MediaService", () => {
500
613
 
501
614
  expect(await mediaService.getById(m1.id)).not.toBeNull();
502
615
  });
616
+
617
+ it("deletes poster keys from storage", async () => {
618
+ const m1 = await mediaService.create({
619
+ ...sampleMedia,
620
+ storageKey: "media/a.mp4",
621
+ posterKey: "media/a-poster.webp",
622
+ });
623
+ const m2 = await mediaService.create({
624
+ ...sampleMedia,
625
+ storageKey: "media/b.jpg",
626
+ });
627
+
628
+ const deletedKeys: string[] = [];
629
+ const mockStorage = {
630
+ delete: async (key: string) => {
631
+ deletedKeys.push(key);
632
+ },
633
+ put: async () => {},
634
+ get: async () => null,
635
+ };
636
+
637
+ await mediaService.deleteByIds([m1.id, m2.id], mockStorage as never);
638
+ expect(deletedKeys).toContain("media/a.mp4");
639
+ expect(deletedKeys).toContain("media/a-poster.webp");
640
+ expect(deletedKeys).toContain("media/b.jpg");
641
+ expect(deletedKeys).toHaveLength(3);
642
+ });
503
643
  });
504
644
  });