@jant/core 0.3.31 → 0.3.33
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/client.css +1 -1
- package/dist/client/client.js +1442 -989
- package/dist/index.js +1429 -1055
- package/package.json +2 -2
- package/src/__tests__/helpers/app.ts +6 -3
- package/src/__tests__/helpers/db.ts +3 -0
- package/src/client.ts +2 -1
- package/src/db/migrations/0011_add_path_registry.sql +23 -0
- package/src/db/schema.ts +12 -1
- package/src/i18n/locales/en.po +225 -91
- package/src/i18n/locales/en.ts +1 -1
- package/src/i18n/locales/zh-Hans.po +201 -152
- package/src/i18n/locales/zh-Hans.ts +1 -1
- package/src/i18n/locales/zh-Hant.po +201 -152
- package/src/i18n/locales/zh-Hant.ts +1 -1
- package/src/lib/__tests__/excerpt.test.ts +25 -0
- package/src/lib/__tests__/resolve-config.test.ts +26 -2
- package/src/lib/__tests__/timeline.test.ts +2 -1
- package/src/lib/compose-bridge.ts +30 -1
- package/src/lib/excerpt.ts +16 -7
- package/src/lib/nav-manager-bridge.ts +54 -0
- package/src/lib/navigation.ts +7 -4
- package/src/lib/render.tsx +5 -2
- package/src/lib/resolve-config.ts +7 -0
- package/src/lib/view.ts +42 -10
- package/src/middleware/error-handler.ts +16 -0
- package/src/routes/api/__tests__/posts.test.ts +80 -0
- package/src/routes/api/__tests__/settings.test.ts +1 -1
- package/src/routes/api/posts.ts +6 -29
- package/src/routes/api/upload.ts +2 -14
- package/src/routes/auth/__tests__/setup.test.ts +2 -1
- package/src/routes/compose.tsx +13 -5
- package/src/routes/dash/__tests__/pages.test.ts +2 -1
- package/src/routes/dash/__tests__/settings-avatar.test.ts +151 -33
- package/src/routes/dash/appearance.tsx +71 -4
- package/src/routes/dash/collections.tsx +15 -21
- package/src/routes/dash/media.tsx +1 -13
- package/src/routes/dash/pages.tsx +5 -150
- package/src/routes/dash/posts.tsx +25 -32
- package/src/routes/dash/redirects.tsx +9 -11
- package/src/routes/dash/settings.tsx +29 -111
- package/src/routes/feed/__tests__/rss.test.ts +5 -1
- package/src/routes/pages/__tests__/collections.test.ts +2 -1
- package/src/routes/pages/__tests__/featured.test.ts +2 -1
- package/src/routes/pages/page.tsx +20 -25
- package/src/services/__tests__/collection.test.ts +2 -1
- package/src/services/__tests__/media.test.ts +78 -1
- package/src/services/__tests__/navigation.test.ts +2 -1
- package/src/services/__tests__/page.test.ts +78 -1
- package/src/services/__tests__/path-registry.test.ts +165 -0
- package/src/services/__tests__/post-timeline.test.ts +2 -1
- package/src/services/__tests__/post.test.ts +103 -1
- package/src/services/__tests__/redirect.test.ts +53 -4
- package/src/services/__tests__/search.test.ts +2 -1
- package/src/services/__tests__/settings.test.ts +153 -0
- package/src/services/index.ts +12 -4
- package/src/services/media.ts +72 -4
- package/src/services/page.ts +64 -17
- package/src/services/path-registry.ts +160 -0
- package/src/services/post.ts +119 -24
- package/src/services/redirect.ts +23 -3
- package/src/services/settings.ts +181 -0
- package/src/styles/components.css +135 -0
- package/src/styles/tokens.css +6 -1
- package/src/styles/ui.css +70 -26
- package/src/types/bindings.ts +1 -0
- package/src/types/config.ts +7 -2
- package/src/types/constants.ts +9 -1
- package/src/types/sortablejs.d.ts +8 -2
- package/src/types/views.ts +1 -1
- package/src/ui/color-themes.ts +31 -31
- package/src/ui/components/__tests__/jant-settings-avatar.test.ts +0 -3
- package/src/ui/components/__tests__/jant-settings-general.test.ts +2 -6
- package/src/ui/components/jant-compose-dialog.ts +3 -2
- package/src/ui/components/jant-compose-editor.ts +17 -2
- package/src/ui/components/jant-nav-manager.ts +1067 -0
- package/src/ui/components/jant-settings-general.ts +2 -35
- package/src/ui/components/nav-manager-types.ts +72 -0
- package/src/ui/components/settings-types.ts +0 -3
- package/src/ui/compose/ComposePrompt.tsx +3 -11
- package/src/ui/dash/appearance/AdvancedContent.tsx +0 -3
- package/src/ui/dash/appearance/AppearanceNav.tsx +12 -8
- package/src/ui/dash/appearance/ColorThemeContent.tsx +1 -4
- package/src/ui/dash/appearance/FontThemeContent.tsx +0 -3
- package/src/ui/dash/appearance/NavigationContent.tsx +302 -0
- package/src/ui/dash/pages/PagesContent.tsx +74 -0
- package/src/ui/dash/settings/AccountContent.tsx +0 -3
- package/src/ui/dash/settings/GeneralContent.tsx +1 -19
- package/src/ui/dash/settings/SettingsNav.tsx +2 -6
- package/src/ui/feed/NoteCard.tsx +2 -2
- package/src/ui/layouts/DashLayout.tsx +83 -86
- package/src/ui/layouts/SiteLayout.tsx +82 -21
- package/src/lib/nav-reorder.ts +0 -26
- package/src/ui/dash/pages/LinkFormContent.tsx +0 -119
- package/src/ui/dash/pages/UnifiedPagesContent.tsx +0 -203
|
@@ -3,6 +3,7 @@ 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";
|
|
6
7
|
import type { Database } from "../../db/index.js";
|
|
7
8
|
|
|
8
9
|
describe("MediaService", () => {
|
|
@@ -14,7 +15,7 @@ describe("MediaService", () => {
|
|
|
14
15
|
const testDb = createTestDatabase();
|
|
15
16
|
db = testDb.db as unknown as Database;
|
|
16
17
|
mediaService = createMediaService(db);
|
|
17
|
-
postService = createPostService(db);
|
|
18
|
+
postService = createPostService(db, createPathRegistryService(db));
|
|
18
19
|
});
|
|
19
20
|
|
|
20
21
|
const sampleMedia = {
|
|
@@ -262,6 +263,51 @@ describe("MediaService", () => {
|
|
|
262
263
|
});
|
|
263
264
|
});
|
|
264
265
|
|
|
266
|
+
describe("validateIds", () => {
|
|
267
|
+
it("passes for valid IDs", async () => {
|
|
268
|
+
const m1 = await mediaService.create({
|
|
269
|
+
...sampleMedia,
|
|
270
|
+
storageKey: "media/a.jpg",
|
|
271
|
+
});
|
|
272
|
+
const m2 = await mediaService.create({
|
|
273
|
+
...sampleMedia,
|
|
274
|
+
storageKey: "media/b.jpg",
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
await expect(
|
|
278
|
+
mediaService.validateIds([m1.id, m2.id]),
|
|
279
|
+
).resolves.not.toThrow();
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("is a no-op for empty array", async () => {
|
|
283
|
+
await expect(mediaService.validateIds([])).resolves.not.toThrow();
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("throws ValidationError when count exceeds limit", async () => {
|
|
287
|
+
const ids = Array.from({ length: 21 }, (_, i) => `fake-id-${i}`);
|
|
288
|
+
await expect(mediaService.validateIds(ids)).rejects.toThrow(
|
|
289
|
+
"at most 20 media attachments",
|
|
290
|
+
);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("throws ValidationError when IDs do not exist", async () => {
|
|
294
|
+
const m1 = await mediaService.create({
|
|
295
|
+
...sampleMedia,
|
|
296
|
+
storageKey: "media/a.jpg",
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
await expect(
|
|
300
|
+
mediaService.validateIds([m1.id, "nonexistent-id"]),
|
|
301
|
+
).rejects.toThrow("media IDs are invalid");
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("throws ValidationError for all nonexistent IDs", async () => {
|
|
305
|
+
await expect(
|
|
306
|
+
mediaService.validateIds(["fake-1", "fake-2"]),
|
|
307
|
+
).rejects.toThrow("media IDs are invalid");
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
|
|
265
311
|
describe("getByStorageKey", () => {
|
|
266
312
|
it("returns media by R2 key", async () => {
|
|
267
313
|
await mediaService.create(sampleMedia);
|
|
@@ -424,4 +470,35 @@ describe("MediaService", () => {
|
|
|
424
470
|
expect(result).toBe(false);
|
|
425
471
|
});
|
|
426
472
|
});
|
|
473
|
+
|
|
474
|
+
describe("deleteByIds", () => {
|
|
475
|
+
it("deletes multiple media records", async () => {
|
|
476
|
+
const m1 = await mediaService.create({
|
|
477
|
+
...sampleMedia,
|
|
478
|
+
storageKey: "media/a.jpg",
|
|
479
|
+
});
|
|
480
|
+
const m2 = await mediaService.create({
|
|
481
|
+
...sampleMedia,
|
|
482
|
+
storageKey: "media/b.jpg",
|
|
483
|
+
});
|
|
484
|
+
const m3 = await mediaService.create({
|
|
485
|
+
...sampleMedia,
|
|
486
|
+
storageKey: "media/c.jpg",
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
await mediaService.deleteByIds([m1.id, m2.id]);
|
|
490
|
+
|
|
491
|
+
expect(await mediaService.getById(m1.id)).toBeNull();
|
|
492
|
+
expect(await mediaService.getById(m2.id)).toBeNull();
|
|
493
|
+
expect(await mediaService.getById(m3.id)).not.toBeNull();
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
it("handles empty array gracefully", async () => {
|
|
497
|
+
const m1 = await mediaService.create(sampleMedia);
|
|
498
|
+
|
|
499
|
+
await mediaService.deleteByIds([]);
|
|
500
|
+
|
|
501
|
+
expect(await mediaService.getById(m1.id)).not.toBeNull();
|
|
502
|
+
});
|
|
503
|
+
});
|
|
427
504
|
});
|
|
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach } from "vitest";
|
|
|
2
2
|
import { createTestDatabase } from "../../__tests__/helpers/db.js";
|
|
3
3
|
import { createNavItemService } from "../navigation.js";
|
|
4
4
|
import { createPageService } from "../page.js";
|
|
5
|
+
import { createPathRegistryService } from "../path-registry.js";
|
|
5
6
|
import type { Database } from "../../db/index.js";
|
|
6
7
|
|
|
7
8
|
describe("NavItemService", () => {
|
|
@@ -13,7 +14,7 @@ describe("NavItemService", () => {
|
|
|
13
14
|
const testDb = createTestDatabase();
|
|
14
15
|
db = testDb.db as unknown as Database;
|
|
15
16
|
navItemService = createNavItemService(db);
|
|
16
|
-
pageService = createPageService(db);
|
|
17
|
+
pageService = createPageService(db, createPathRegistryService(db));
|
|
17
18
|
});
|
|
18
19
|
|
|
19
20
|
describe("create", () => {
|
|
@@ -1,18 +1,25 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
2
|
import { createTestDatabase } from "../../__tests__/helpers/db.js";
|
|
3
3
|
import { createPageService } from "../page.js";
|
|
4
|
+
import { createPostService } from "../post.js";
|
|
4
5
|
import { createNavItemService } from "../navigation.js";
|
|
6
|
+
import { createPathRegistryService } from "../path-registry.js";
|
|
7
|
+
import { ValidationError, ConflictError } from "../../lib/errors.js";
|
|
5
8
|
import type { Database } from "../../db/index.js";
|
|
6
9
|
|
|
7
10
|
describe("PageService", () => {
|
|
8
11
|
let db: Database;
|
|
9
12
|
let pageService: ReturnType<typeof createPageService>;
|
|
13
|
+
let postService: ReturnType<typeof createPostService>;
|
|
10
14
|
let navItemService: ReturnType<typeof createNavItemService>;
|
|
15
|
+
let pathRegistry: ReturnType<typeof createPathRegistryService>;
|
|
11
16
|
|
|
12
17
|
beforeEach(() => {
|
|
13
18
|
const testDb = createTestDatabase();
|
|
14
19
|
db = testDb.db as unknown as Database;
|
|
15
|
-
|
|
20
|
+
pathRegistry = createPathRegistryService(db);
|
|
21
|
+
pageService = createPageService(db, pathRegistry);
|
|
22
|
+
postService = createPostService(db, pathRegistry);
|
|
16
23
|
navItemService = createNavItemService(db);
|
|
17
24
|
});
|
|
18
25
|
|
|
@@ -218,4 +225,74 @@ describe("PageService", () => {
|
|
|
218
225
|
expect(contactNav?.label).toBe("Contact");
|
|
219
226
|
});
|
|
220
227
|
});
|
|
228
|
+
|
|
229
|
+
describe("path registry integration", () => {
|
|
230
|
+
it("rejects reserved slug on create", async () => {
|
|
231
|
+
await expect(
|
|
232
|
+
pageService.create({ slug: "dash", title: "Dashboard" }),
|
|
233
|
+
).rejects.toThrow(ValidationError);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("rejects slug that conflicts with a post path", async () => {
|
|
237
|
+
await postService.create({
|
|
238
|
+
format: "note",
|
|
239
|
+
body: "test",
|
|
240
|
+
path: "about",
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
await expect(
|
|
244
|
+
pageService.create({ slug: "about", title: "About" }),
|
|
245
|
+
).rejects.toThrow(ConflictError);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("rejects reserved slug on update", async () => {
|
|
249
|
+
const page = await pageService.create({
|
|
250
|
+
slug: "about",
|
|
251
|
+
title: "About",
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
await expect(
|
|
255
|
+
pageService.update(page.id, { slug: "api" }),
|
|
256
|
+
).rejects.toThrow(ValidationError);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("rejects slug update that conflicts with another entity", async () => {
|
|
260
|
+
const page = await pageService.create({
|
|
261
|
+
slug: "about",
|
|
262
|
+
title: "About",
|
|
263
|
+
});
|
|
264
|
+
await postService.create({
|
|
265
|
+
format: "note",
|
|
266
|
+
body: "test",
|
|
267
|
+
path: "contact",
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
await expect(
|
|
271
|
+
pageService.update(page.id, { slug: "contact" }),
|
|
272
|
+
).rejects.toThrow(ConflictError);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("releases path on delete", async () => {
|
|
276
|
+
const page = await pageService.create({
|
|
277
|
+
slug: "about",
|
|
278
|
+
title: "About",
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
await pageService.delete(page.id);
|
|
282
|
+
|
|
283
|
+
expect(await pathRegistry.isAvailable("about")).toBe(true);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("releases old slug and claims new slug on update", async () => {
|
|
287
|
+
const page = await pageService.create({
|
|
288
|
+
slug: "about",
|
|
289
|
+
title: "About",
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
await pageService.update(page.id, { slug: "about-us" });
|
|
293
|
+
|
|
294
|
+
expect(await pathRegistry.isAvailable("about")).toBe(true);
|
|
295
|
+
expect(await pathRegistry.isAvailable("about-us")).toBe(false);
|
|
296
|
+
});
|
|
297
|
+
});
|
|
221
298
|
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
import { createTestDatabase } from "../../__tests__/helpers/db.js";
|
|
3
|
+
import { createPathRegistryService } from "../path-registry.js";
|
|
4
|
+
import { ValidationError, ConflictError } from "../../lib/errors.js";
|
|
5
|
+
import type { Database } from "../../db/index.js";
|
|
6
|
+
|
|
7
|
+
describe("PathRegistryService", () => {
|
|
8
|
+
let db: Database;
|
|
9
|
+
let pathRegistry: ReturnType<typeof createPathRegistryService>;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
const testDb = createTestDatabase();
|
|
13
|
+
db = testDb.db as unknown as Database;
|
|
14
|
+
pathRegistry = createPathRegistryService(db);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("claim", () => {
|
|
18
|
+
it("claims a path successfully", async () => {
|
|
19
|
+
const entry = await pathRegistry.claim("about", "page", 1);
|
|
20
|
+
|
|
21
|
+
expect(entry.path).toBe("about");
|
|
22
|
+
expect(entry.ownerType).toBe("page");
|
|
23
|
+
expect(entry.ownerId).toBe(1);
|
|
24
|
+
expect(entry.createdAt).toBeGreaterThan(0);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("normalizes the path before claiming", async () => {
|
|
28
|
+
const entry = await pathRegistry.claim(" /About/ ", "page", 1);
|
|
29
|
+
expect(entry.path).toBe("about");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("rejects reserved paths", async () => {
|
|
33
|
+
await expect(pathRegistry.claim("dash", "page", 1)).rejects.toThrow(
|
|
34
|
+
ValidationError,
|
|
35
|
+
);
|
|
36
|
+
await expect(pathRegistry.claim("api", "page", 1)).rejects.toThrow(
|
|
37
|
+
ValidationError,
|
|
38
|
+
);
|
|
39
|
+
await expect(pathRegistry.claim("search", "page", 1)).rejects.toThrow(
|
|
40
|
+
ValidationError,
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("rejects reserved paths regardless of casing", async () => {
|
|
45
|
+
await expect(pathRegistry.claim("DASH", "page", 1)).rejects.toThrow(
|
|
46
|
+
ValidationError,
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("throws ConflictError when path is already claimed by another entity", async () => {
|
|
51
|
+
await pathRegistry.claim("about", "page", 1);
|
|
52
|
+
|
|
53
|
+
await expect(pathRegistry.claim("about", "post", 2)).rejects.toThrow(
|
|
54
|
+
ConflictError,
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("is idempotent for the same owner", async () => {
|
|
59
|
+
const first = await pathRegistry.claim("about", "page", 1);
|
|
60
|
+
const second = await pathRegistry.claim("about", "page", 1);
|
|
61
|
+
|
|
62
|
+
expect(second.path).toBe(first.path);
|
|
63
|
+
expect(second.ownerType).toBe(first.ownerType);
|
|
64
|
+
expect(second.ownerId).toBe(first.ownerId);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("allows multi-level paths", async () => {
|
|
68
|
+
const entry = await pathRegistry.claim("2024/01/my-post", "post", 1);
|
|
69
|
+
expect(entry.path).toBe("2024/01/my-post");
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("release", () => {
|
|
74
|
+
it("releases a claimed path", async () => {
|
|
75
|
+
await pathRegistry.claim("about", "page", 1);
|
|
76
|
+
await pathRegistry.release("about");
|
|
77
|
+
|
|
78
|
+
const entry = await pathRegistry.getByPath("about");
|
|
79
|
+
expect(entry).toBeNull();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("normalizes the path before releasing", async () => {
|
|
83
|
+
await pathRegistry.claim("about", "page", 1);
|
|
84
|
+
await pathRegistry.release(" /About/ ");
|
|
85
|
+
|
|
86
|
+
const entry = await pathRegistry.getByPath("about");
|
|
87
|
+
expect(entry).toBeNull();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("is a no-op for unclaimed paths", async () => {
|
|
91
|
+
// Should not throw
|
|
92
|
+
await pathRegistry.release("nonexistent");
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("releaseByOwner", () => {
|
|
97
|
+
it("releases all paths for a specific owner", async () => {
|
|
98
|
+
await pathRegistry.claim("about", "page", 1);
|
|
99
|
+
await pathRegistry.claim("contact", "page", 1);
|
|
100
|
+
await pathRegistry.claim("blog", "page", 2);
|
|
101
|
+
|
|
102
|
+
await pathRegistry.releaseByOwner("page", 1);
|
|
103
|
+
|
|
104
|
+
expect(await pathRegistry.getByPath("about")).toBeNull();
|
|
105
|
+
expect(await pathRegistry.getByPath("contact")).toBeNull();
|
|
106
|
+
// Different owner's path should remain
|
|
107
|
+
expect(await pathRegistry.getByPath("blog")).not.toBeNull();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("does not affect other owner types", async () => {
|
|
111
|
+
await pathRegistry.claim("about", "page", 1);
|
|
112
|
+
await pathRegistry.claim("my-post", "post", 1);
|
|
113
|
+
|
|
114
|
+
await pathRegistry.releaseByOwner("page", 1);
|
|
115
|
+
|
|
116
|
+
expect(await pathRegistry.getByPath("about")).toBeNull();
|
|
117
|
+
expect(await pathRegistry.getByPath("my-post")).not.toBeNull();
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("getByPath", () => {
|
|
122
|
+
it("returns entry for claimed path", async () => {
|
|
123
|
+
await pathRegistry.claim("about", "page", 1);
|
|
124
|
+
|
|
125
|
+
const entry = await pathRegistry.getByPath("about");
|
|
126
|
+
expect(entry).not.toBeNull();
|
|
127
|
+
expect(entry?.ownerType).toBe("page");
|
|
128
|
+
expect(entry?.ownerId).toBe(1);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("normalizes the lookup path", async () => {
|
|
132
|
+
await pathRegistry.claim("about", "page", 1);
|
|
133
|
+
|
|
134
|
+
const entry = await pathRegistry.getByPath(" /About/ ");
|
|
135
|
+
expect(entry).not.toBeNull();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("returns null for unclaimed path", async () => {
|
|
139
|
+
const entry = await pathRegistry.getByPath("nonexistent");
|
|
140
|
+
expect(entry).toBeNull();
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("isAvailable", () => {
|
|
145
|
+
it("returns true for unclaimed, non-reserved paths", async () => {
|
|
146
|
+
expect(await pathRegistry.isAvailable("about")).toBe(true);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("returns false for reserved paths", async () => {
|
|
150
|
+
expect(await pathRegistry.isAvailable("dash")).toBe(false);
|
|
151
|
+
expect(await pathRegistry.isAvailable("api")).toBe(false);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("returns false for claimed paths", async () => {
|
|
155
|
+
await pathRegistry.claim("about", "page", 1);
|
|
156
|
+
expect(await pathRegistry.isAvailable("about")).toBe(false);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("returns true after a path is released", async () => {
|
|
160
|
+
await pathRegistry.claim("about", "page", 1);
|
|
161
|
+
await pathRegistry.release("about");
|
|
162
|
+
expect(await pathRegistry.isAvailable("about")).toBe(true);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
2
|
import { createTestDatabase } from "../../__tests__/helpers/db.js";
|
|
3
3
|
import { createPostService } from "../post.js";
|
|
4
|
+
import { createPathRegistryService } from "../path-registry.js";
|
|
4
5
|
import type { Database } from "../../db/index.js";
|
|
5
6
|
|
|
6
7
|
describe("PostService - Timeline features", () => {
|
|
@@ -10,7 +11,7 @@ describe("PostService - Timeline features", () => {
|
|
|
10
11
|
beforeEach(() => {
|
|
11
12
|
const testDb = createTestDatabase();
|
|
12
13
|
db = testDb.db as unknown as Database;
|
|
13
|
-
postService = createPostService(db);
|
|
14
|
+
postService = createPostService(db, createPathRegistryService(db));
|
|
14
15
|
});
|
|
15
16
|
|
|
16
17
|
describe("format filter", () => {
|
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
2
|
import { createTestDatabase } from "../../__tests__/helpers/db.js";
|
|
3
3
|
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";
|
|
4
7
|
import type { Database } from "../../db/index.js";
|
|
5
8
|
|
|
6
9
|
describe("PostService", () => {
|
|
7
10
|
let db: Database;
|
|
8
11
|
let postService: ReturnType<typeof createPostService>;
|
|
12
|
+
let pageService: ReturnType<typeof createPageService>;
|
|
13
|
+
let pathRegistry: ReturnType<typeof createPathRegistryService>;
|
|
9
14
|
|
|
10
15
|
beforeEach(() => {
|
|
11
16
|
const testDb = createTestDatabase();
|
|
12
17
|
db = testDb.db as unknown as Database;
|
|
13
|
-
|
|
18
|
+
pathRegistry = createPathRegistryService(db);
|
|
19
|
+
postService = createPostService(db, pathRegistry);
|
|
20
|
+
pageService = createPageService(db, pathRegistry);
|
|
14
21
|
});
|
|
15
22
|
|
|
16
23
|
describe("create", () => {
|
|
@@ -868,4 +875,99 @@ describe("PostService", () => {
|
|
|
868
875
|
expect(counts.get(root.id)).toBe(1);
|
|
869
876
|
});
|
|
870
877
|
});
|
|
878
|
+
|
|
879
|
+
describe("path registry integration", () => {
|
|
880
|
+
it("claims path on create", async () => {
|
|
881
|
+
const post = await postService.create({
|
|
882
|
+
format: "note",
|
|
883
|
+
body: "test",
|
|
884
|
+
path: "my-post",
|
|
885
|
+
});
|
|
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
|
+
await postService.create({
|
|
895
|
+
format: "note",
|
|
896
|
+
body: "test",
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
// No path registry entries should exist
|
|
900
|
+
expect(await pathRegistry.isAvailable("test")).toBe(true);
|
|
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);
|
|
915
|
+
});
|
|
916
|
+
|
|
917
|
+
it("releases old path and claims new on update", async () => {
|
|
918
|
+
const post = await postService.create({
|
|
919
|
+
format: "note",
|
|
920
|
+
body: "test",
|
|
921
|
+
path: "old-path",
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
await postService.update(post.id, { path: "new-path" });
|
|
925
|
+
|
|
926
|
+
expect(await pathRegistry.isAvailable("old-path")).toBe(true);
|
|
927
|
+
expect(await pathRegistry.isAvailable("new-path")).toBe(false);
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
it("releases path when cleared to null on update", async () => {
|
|
931
|
+
const post = await postService.create({
|
|
932
|
+
format: "note",
|
|
933
|
+
body: "test",
|
|
934
|
+
path: "my-path",
|
|
935
|
+
});
|
|
936
|
+
|
|
937
|
+
await postService.update(post.id, { path: null });
|
|
938
|
+
|
|
939
|
+
expect(await pathRegistry.isAvailable("my-path")).toBe(true);
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
it("releases path on soft-delete", async () => {
|
|
943
|
+
const post = await postService.create({
|
|
944
|
+
format: "note",
|
|
945
|
+
body: "test",
|
|
946
|
+
path: "my-path",
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
await postService.delete(post.id);
|
|
950
|
+
|
|
951
|
+
expect(await pathRegistry.isAvailable("my-path")).toBe(true);
|
|
952
|
+
});
|
|
953
|
+
|
|
954
|
+
it("releases paths for all thread posts on root delete", async () => {
|
|
955
|
+
const root = await postService.create({
|
|
956
|
+
format: "note",
|
|
957
|
+
body: "root",
|
|
958
|
+
path: "thread-root",
|
|
959
|
+
});
|
|
960
|
+
await postService.create({
|
|
961
|
+
format: "note",
|
|
962
|
+
body: "reply",
|
|
963
|
+
path: "thread-reply",
|
|
964
|
+
replyToId: root.id,
|
|
965
|
+
});
|
|
966
|
+
|
|
967
|
+
await postService.delete(root.id);
|
|
968
|
+
|
|
969
|
+
expect(await pathRegistry.isAvailable("thread-root")).toBe(true);
|
|
970
|
+
expect(await pathRegistry.isAvailable("thread-reply")).toBe(true);
|
|
971
|
+
});
|
|
972
|
+
});
|
|
871
973
|
});
|
|
@@ -1,16 +1,26 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
2
|
import { createTestDatabase } from "../../__tests__/helpers/db.js";
|
|
3
3
|
import { createRedirectService } from "../redirect.js";
|
|
4
|
+
import { createPageService } from "../page.js";
|
|
5
|
+
import { createPostService } from "../post.js";
|
|
6
|
+
import { createPathRegistryService } from "../path-registry.js";
|
|
7
|
+
import { ConflictError } from "../../lib/errors.js";
|
|
4
8
|
import type { Database } from "../../db/index.js";
|
|
5
9
|
|
|
6
10
|
describe("RedirectService", () => {
|
|
7
11
|
let db: Database;
|
|
8
12
|
let redirectService: ReturnType<typeof createRedirectService>;
|
|
13
|
+
let pageService: ReturnType<typeof createPageService>;
|
|
14
|
+
let postService: ReturnType<typeof createPostService>;
|
|
15
|
+
let pathRegistry: ReturnType<typeof createPathRegistryService>;
|
|
9
16
|
|
|
10
17
|
beforeEach(() => {
|
|
11
18
|
const testDb = createTestDatabase();
|
|
12
19
|
db = testDb.db as unknown as Database;
|
|
13
|
-
|
|
20
|
+
pathRegistry = createPathRegistryService(db);
|
|
21
|
+
redirectService = createRedirectService(db, pathRegistry);
|
|
22
|
+
pageService = createPageService(db, pathRegistry);
|
|
23
|
+
postService = createPostService(db, pathRegistry);
|
|
14
24
|
});
|
|
15
25
|
|
|
16
26
|
describe("create", () => {
|
|
@@ -99,12 +109,51 @@ describe("RedirectService", () => {
|
|
|
99
109
|
});
|
|
100
110
|
|
|
101
111
|
it("returns all redirects", async () => {
|
|
102
|
-
await redirectService.create("/a", "/
|
|
103
|
-
await redirectService.create("/
|
|
104
|
-
await redirectService.create("/
|
|
112
|
+
await redirectService.create("/old-a", "/new-a");
|
|
113
|
+
await redirectService.create("/old-b", "/new-b");
|
|
114
|
+
await redirectService.create("/old-c", "/new-c");
|
|
105
115
|
|
|
106
116
|
const redirects = await redirectService.list();
|
|
107
117
|
expect(redirects).toHaveLength(3);
|
|
108
118
|
});
|
|
109
119
|
});
|
|
120
|
+
|
|
121
|
+
describe("path registry integration", () => {
|
|
122
|
+
it("rejects redirect that conflicts with a page", async () => {
|
|
123
|
+
await pageService.create({ slug: "about", title: "About" });
|
|
124
|
+
|
|
125
|
+
await expect(
|
|
126
|
+
redirectService.create("/about", "/new-about"),
|
|
127
|
+
).rejects.toThrow(ConflictError);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("rejects redirect that conflicts with a post path", async () => {
|
|
131
|
+
await postService.create({
|
|
132
|
+
format: "note",
|
|
133
|
+
body: "test",
|
|
134
|
+
path: "my-post",
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
await expect(
|
|
138
|
+
redirectService.create("/my-post", "/elsewhere"),
|
|
139
|
+
).rejects.toThrow(ConflictError);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("allows upsert for existing redirect (same type)", async () => {
|
|
143
|
+
await redirectService.create("/old", "/first");
|
|
144
|
+
const second = await redirectService.create("/old", "/second");
|
|
145
|
+
|
|
146
|
+
expect(second.toPath).toBe("/second");
|
|
147
|
+
|
|
148
|
+
const list = await redirectService.list();
|
|
149
|
+
expect(list).toHaveLength(1);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("releases path on delete", async () => {
|
|
153
|
+
const redirect = await redirectService.create("/old", "/new");
|
|
154
|
+
await redirectService.delete(redirect.id);
|
|
155
|
+
|
|
156
|
+
expect(await pathRegistry.isAvailable("old")).toBe(true);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
110
159
|
});
|
|
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach } from "vitest";
|
|
|
2
2
|
import { createTestDatabase } from "../../__tests__/helpers/db.js";
|
|
3
3
|
import { createSearchService } from "../search.js";
|
|
4
4
|
import { createPostService } from "../post.js";
|
|
5
|
+
import { createPathRegistryService } from "../path-registry.js";
|
|
5
6
|
import type { Database } from "../../db/index.js";
|
|
6
7
|
import type BetterSqlite3 from "better-sqlite3";
|
|
7
8
|
|
|
@@ -33,7 +34,7 @@ describe("SearchService", () => {
|
|
|
33
34
|
const testDb = createTestDatabase({ fts: true });
|
|
34
35
|
db = testDb.db as unknown as Database;
|
|
35
36
|
sqlite = testDb.sqlite;
|
|
36
|
-
postService = createPostService(db);
|
|
37
|
+
postService = createPostService(db, createPathRegistryService(db));
|
|
37
38
|
});
|
|
38
39
|
|
|
39
40
|
it("returns empty results for empty query", async () => {
|