@jant/core 0.3.1 → 0.3.3

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 (41) hide show
  1. package/dist/app.d.ts.map +1 -1
  2. package/dist/app.js +0 -2
  3. package/dist/lib/constants.d.ts +1 -1
  4. package/dist/lib/constants.d.ts.map +1 -1
  5. package/dist/lib/constants.js +1 -2
  6. package/dist/routes/api/posts.js +1 -1
  7. package/dist/routes/dash/settings.d.ts +2 -0
  8. package/dist/routes/dash/settings.d.ts.map +1 -1
  9. package/dist/routes/dash/settings.js +413 -93
  10. package/dist/theme/layouts/DashLayout.d.ts.map +1 -1
  11. package/dist/theme/layouts/DashLayout.js +0 -8
  12. package/package.json +10 -5
  13. package/src/__tests__/helpers/app.ts +97 -0
  14. package/src/__tests__/helpers/db.ts +85 -0
  15. package/src/app.tsx +0 -3
  16. package/src/db/migrations/0001_add_search_fts.sql +34 -0
  17. package/src/db/migrations/meta/_journal.json +7 -0
  18. package/src/lib/__tests__/constants.test.ts +44 -0
  19. package/src/lib/__tests__/markdown.test.ts +133 -0
  20. package/src/lib/__tests__/schemas.test.ts +220 -0
  21. package/src/lib/__tests__/sqid.test.ts +65 -0
  22. package/src/lib/__tests__/sse.test.ts +86 -0
  23. package/src/lib/__tests__/time.test.ts +112 -0
  24. package/src/lib/__tests__/url.test.ts +138 -0
  25. package/src/lib/constants.ts +0 -1
  26. package/src/middleware/__tests__/auth.test.ts +139 -0
  27. package/src/routes/api/__tests__/posts.test.ts +306 -0
  28. package/src/routes/api/__tests__/search.test.ts +77 -0
  29. package/src/routes/api/posts.ts +3 -1
  30. package/src/routes/dash/settings.tsx +350 -16
  31. package/src/services/__tests__/collection.test.ts +226 -0
  32. package/src/services/__tests__/media.test.ts +134 -0
  33. package/src/services/__tests__/post.test.ts +636 -0
  34. package/src/services/__tests__/redirect.test.ts +110 -0
  35. package/src/services/__tests__/search.test.ts +143 -0
  36. package/src/services/__tests__/settings.test.ts +110 -0
  37. package/src/theme/layouts/DashLayout.tsx +0 -9
  38. package/dist/routes/dash/appearance.d.ts +0 -13
  39. package/dist/routes/dash/appearance.d.ts.map +0 -1
  40. package/dist/routes/dash/appearance.js +0 -160
  41. package/src/routes/dash/appearance.tsx +0 -176
@@ -1,5 +1,7 @@
1
1
  /**
2
2
  * Dashboard Settings Routes
3
+ *
4
+ * Sub-pages: General, Appearance, Account
3
5
  */
4
6
 
5
7
  import { Hono } from "hono";
@@ -7,8 +9,15 @@ import { useLingui } from "@lingui/react/macro";
7
9
  import type { Bindings } from "../../types.js";
8
10
  import type { AppVariables } from "../../app.js";
9
11
  import { DashLayout } from "../../theme/layouts/index.js";
10
- import { sse, dsToast } from "../../lib/sse.js";
11
- import { getSiteLanguage, getConfigFallback } from "../../lib/config.js";
12
+ import { sse, dsRedirect, dsToast } from "../../lib/sse.js";
13
+ import {
14
+ getSiteLanguage,
15
+ getSiteName,
16
+ getConfigFallback,
17
+ } from "../../lib/config.js";
18
+ import { SETTINGS_KEYS } from "../../lib/constants.js";
19
+ import { getAvailableThemes } from "../../lib/theme.js";
20
+ import type { ColorTheme } from "../../theme/color-themes.js";
12
21
 
13
22
  /** Escape HTML special characters for safe insertion into HTML strings */
14
23
  function escapeHtml(str: string): string {
@@ -23,7 +32,66 @@ type Env = { Bindings: Bindings; Variables: AppVariables };
23
32
 
24
33
  export const settingsRoutes = new Hono<Env>();
25
34
 
26
- function SettingsContent({
35
+ // ---------------------------------------------------------------------------
36
+ // Shared sub-navigation
37
+ // ---------------------------------------------------------------------------
38
+
39
+ type SettingsTab = "general" | "appearance" | "account";
40
+
41
+ function SettingsNav({ currentTab }: { currentTab: SettingsTab }) {
42
+ const { t } = useLingui();
43
+
44
+ const tabs: { id: SettingsTab; label: string; href: string }[] = [
45
+ {
46
+ id: "general",
47
+ label: t({
48
+ message: "General",
49
+ comment: "@context: Settings sub-navigation tab",
50
+ }),
51
+ href: "/dash/settings",
52
+ },
53
+ {
54
+ id: "appearance",
55
+ label: t({
56
+ message: "Appearance",
57
+ comment: "@context: Settings sub-navigation tab",
58
+ }),
59
+ href: "/dash/settings/appearance",
60
+ },
61
+ {
62
+ id: "account",
63
+ label: t({
64
+ message: "Account",
65
+ comment: "@context: Settings sub-navigation tab",
66
+ }),
67
+ href: "/dash/settings/account",
68
+ },
69
+ ];
70
+
71
+ return (
72
+ <nav class="flex gap-1 mb-6">
73
+ {tabs.map((tab) => (
74
+ <a
75
+ key={tab.id}
76
+ href={tab.href}
77
+ class={`px-3 py-2 text-sm rounded-md ${
78
+ tab.id === currentTab
79
+ ? "bg-accent text-accent-foreground font-medium"
80
+ : "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
81
+ }`}
82
+ >
83
+ {tab.label}
84
+ </a>
85
+ ))}
86
+ </nav>
87
+ );
88
+ }
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // General tab
92
+ // ---------------------------------------------------------------------------
93
+
94
+ function GeneralContent({
27
95
  siteName,
28
96
  siteDescription,
29
97
  siteLanguage,
@@ -46,9 +114,10 @@ function SettingsContent({
46
114
 
47
115
  return (
48
116
  <>
49
- <h1 class="text-2xl font-semibold mb-6">
117
+ <h1 class="text-2xl font-semibold mb-2">
50
118
  {t({ message: "Settings", comment: "@context: Dashboard heading" })}
51
119
  </h1>
120
+ <SettingsNav currentTab="general" />
52
121
 
53
122
  <div class="flex flex-col gap-6 max-w-lg">
54
123
  <form
@@ -126,6 +195,192 @@ function SettingsContent({
126
195
  })}
127
196
  </button>
128
197
  </form>
198
+ </div>
199
+ </>
200
+ );
201
+ }
202
+
203
+ // ---------------------------------------------------------------------------
204
+ // Appearance tab
205
+ // ---------------------------------------------------------------------------
206
+
207
+ function ThemeCard({
208
+ theme,
209
+ selected,
210
+ }: {
211
+ theme: ColorTheme;
212
+ selected: boolean;
213
+ }) {
214
+ const expr = `$theme === '${theme.id}'`;
215
+ const { preview } = theme;
216
+
217
+ return (
218
+ <label
219
+ class={`block cursor-pointer rounded-lg border overflow-hidden transition-colors ${selected ? "border-primary" : "border-border"}`}
220
+ data-class:border-primary={expr}
221
+ data-class:border-border={`$theme !== '${theme.id}'`}
222
+ >
223
+ <div class="grid grid-cols-2">
224
+ <div
225
+ class="p-5"
226
+ style={`background-color:${preview.lightBg};color:${preview.lightText}`}
227
+ >
228
+ <input
229
+ type="radio"
230
+ name="theme"
231
+ value={theme.id}
232
+ data-bind="theme"
233
+ checked={selected || undefined}
234
+ class="mb-1"
235
+ />
236
+ <h3 class="font-bold text-lg">{theme.name}</h3>
237
+ <p class="text-sm mt-2 leading-relaxed">
238
+ This is the {theme.name} theme in light mode. Links{" "}
239
+ <a
240
+ tabIndex={-1}
241
+ class="underline"
242
+ style={`color:${preview.lightLink}`}
243
+ >
244
+ look like this
245
+ </a>
246
+ . We'll show the correct light or dark mode based on your visitor's
247
+ settings.
248
+ </p>
249
+ </div>
250
+ <div
251
+ class="p-5"
252
+ style={`background-color:${preview.darkBg};color:${preview.darkText}`}
253
+ >
254
+ <h3 class="font-bold text-lg">{theme.name}</h3>
255
+ <p class="text-sm mt-2 leading-relaxed">
256
+ This is the {theme.name} theme in dark mode. Links{" "}
257
+ <a
258
+ tabIndex={-1}
259
+ class="underline"
260
+ style={`color:${preview.darkLink}`}
261
+ >
262
+ look like this
263
+ </a>
264
+ . We'll show the correct light or dark mode based on your visitor's
265
+ settings.
266
+ </p>
267
+ </div>
268
+ </div>
269
+ </label>
270
+ );
271
+ }
272
+
273
+ function AppearanceContent({
274
+ themes,
275
+ currentThemeId,
276
+ }: {
277
+ themes: ColorTheme[];
278
+ currentThemeId: string;
279
+ }) {
280
+ const { t } = useLingui();
281
+
282
+ const signals = JSON.stringify({ theme: currentThemeId }).replace(
283
+ /</g,
284
+ "\\u003c",
285
+ );
286
+
287
+ return (
288
+ <>
289
+ <h1 class="text-2xl font-semibold mb-2">
290
+ {t({ message: "Settings", comment: "@context: Dashboard heading" })}
291
+ </h1>
292
+ <SettingsNav currentTab="appearance" />
293
+
294
+ <div
295
+ data-signals={signals}
296
+ data-on:change="@post('/dash/settings/appearance')"
297
+ class="max-w-3xl"
298
+ >
299
+ <fieldset>
300
+ <legend class="text-lg font-semibold">
301
+ {t({
302
+ message: "Color theme",
303
+ comment: "@context: Appearance settings heading",
304
+ })}
305
+ </legend>
306
+ <p class="text-sm text-muted-foreground mb-4">
307
+ {t({
308
+ message:
309
+ "This will theme both your site and your dashboard. All color themes support dark mode.",
310
+ comment: "@context: Appearance settings description",
311
+ })}
312
+ </p>
313
+
314
+ <div class="flex flex-col gap-4">
315
+ {themes.map((theme) => (
316
+ <ThemeCard
317
+ key={theme.id}
318
+ theme={theme}
319
+ selected={theme.id === currentThemeId}
320
+ />
321
+ ))}
322
+ </div>
323
+ </fieldset>
324
+ </div>
325
+ </>
326
+ );
327
+ }
328
+
329
+ // ---------------------------------------------------------------------------
330
+ // Account tab
331
+ // ---------------------------------------------------------------------------
332
+
333
+ function AccountContent({ userName }: { userName: string }) {
334
+ const { t } = useLingui();
335
+
336
+ const profileSignals = JSON.stringify({ userName }).replace(/</g, "\\u003c");
337
+
338
+ return (
339
+ <>
340
+ <h1 class="text-2xl font-semibold mb-2">
341
+ {t({ message: "Settings", comment: "@context: Dashboard heading" })}
342
+ </h1>
343
+ <SettingsNav currentTab="account" />
344
+
345
+ <div class="flex flex-col gap-6 max-w-lg">
346
+ <form
347
+ data-signals={profileSignals}
348
+ data-on:submit__prevent="@post('/dash/settings/account')"
349
+ >
350
+ <div class="card">
351
+ <header>
352
+ <h2>
353
+ {t({
354
+ message: "Profile",
355
+ comment: "@context: Account settings section heading",
356
+ })}
357
+ </h2>
358
+ </header>
359
+ <section class="flex flex-col gap-4">
360
+ <div class="field">
361
+ <label class="label">
362
+ {t({
363
+ message: "Name",
364
+ comment: "@context: Account settings form field",
365
+ })}
366
+ </label>
367
+ <input
368
+ type="text"
369
+ data-bind="userName"
370
+ class="input"
371
+ required
372
+ />
373
+ </div>
374
+ </section>
375
+ </div>
376
+
377
+ <button type="submit" class="btn mt-4">
378
+ {t({
379
+ message: "Save Profile",
380
+ comment: "@context: Button to save profile",
381
+ })}
382
+ </button>
383
+ </form>
129
384
 
130
385
  <form
131
386
  data-signals="{currentPassword: '', newPassword: '', confirmPassword: ''}"
@@ -205,16 +460,18 @@ function SettingsContent({
205
460
  );
206
461
  }
207
462
 
208
- // Settings page
463
+ // ===========================================================================
464
+ // Route handlers
465
+ // ===========================================================================
466
+
467
+ // General settings page
209
468
  settingsRoutes.get("/", async (c) => {
210
469
  const { settings } = c.var.services;
211
470
 
212
- // Fetch raw DB values (null if not set)
213
471
  const dbSiteName = await settings.get("SITE_NAME");
214
472
  const dbSiteDescription = await settings.get("SITE_DESCRIPTION");
215
473
  const siteLanguage = await getSiteLanguage(c);
216
474
 
217
- // Fallback values (ENV > Default) for placeholders
218
475
  const siteNameFallback = getConfigFallback(c, "SITE_NAME");
219
476
  const siteDescriptionFallback = getConfigFallback(c, "SITE_DESCRIPTION");
220
477
 
@@ -228,7 +485,7 @@ settingsRoutes.get("/", async (c) => {
228
485
  currentPath="/dash/settings"
229
486
  toast={saved ? { message: "Settings saved successfully." } : undefined}
230
487
  >
231
- <SettingsContent
488
+ <GeneralContent
232
489
  siteName={dbSiteName || ""}
233
490
  siteDescription={dbSiteDescription || ""}
234
491
  siteLanguage={siteLanguage}
@@ -239,7 +496,7 @@ settingsRoutes.get("/", async (c) => {
239
496
  );
240
497
  });
241
498
 
242
- // Update settings
499
+ // Save general settings
243
500
  settingsRoutes.post("/", async (c) => {
244
501
  const body = await c.req.json<{
245
502
  siteName: string;
@@ -251,7 +508,6 @@ settingsRoutes.post("/", async (c) => {
251
508
 
252
509
  const oldLanguage = (await settings.get("SITE_LANGUAGE")) ?? "en";
253
510
 
254
- // For text fields: empty = remove from DB (fall back to ENV > Default)
255
511
  if (body.siteName.trim()) {
256
512
  await settings.set("SITE_NAME", body.siteName.trim());
257
513
  } else {
@@ -264,25 +520,19 @@ settingsRoutes.post("/", async (c) => {
264
520
  await settings.remove("SITE_DESCRIPTION");
265
521
  }
266
522
 
267
- // Language always has a value from the select
268
523
  await settings.set("SITE_LANGUAGE", body.siteLanguage);
269
524
 
270
525
  const languageChanged = oldLanguage !== body.siteLanguage;
271
-
272
- // Determine the effective display name after save
273
526
  const displayName = body.siteName.trim() || getConfigFallback(c, "SITE_NAME");
274
527
 
275
528
  return sse(c, async (stream) => {
276
529
  if (languageChanged) {
277
- // Language changed - full reload needed to update all UI text
278
530
  await stream.redirect("/dash/settings?saved");
279
531
  } else {
280
532
  const escaped = escapeHtml(displayName);
281
- // Update header site name
282
533
  await stream.patchElements(
283
534
  `<a id="site-name" href="/dash" class="font-semibold">${escaped}</a>`,
284
535
  );
285
- // Update page title
286
536
  await stream.patchElements(`Settings - ${escaped}`, {
287
537
  mode: "inner",
288
538
  selector: "title",
@@ -292,6 +542,90 @@ settingsRoutes.post("/", async (c) => {
292
542
  });
293
543
  });
294
544
 
545
+ // Appearance page
546
+ settingsRoutes.get("/appearance", async (c) => {
547
+ const { settings } = c.var.services;
548
+ const siteName = await getSiteName(c);
549
+ const currentThemeId = (await settings.get(SETTINGS_KEYS.THEME)) ?? "default";
550
+ const themes = getAvailableThemes(c.var.config);
551
+ const saved = c.req.query("saved") !== undefined;
552
+
553
+ return c.html(
554
+ <DashLayout
555
+ c={c}
556
+ title="Settings"
557
+ siteName={siteName}
558
+ currentPath="/dash/settings"
559
+ toast={saved ? { message: "Theme saved successfully." } : undefined}
560
+ >
561
+ <AppearanceContent themes={themes} currentThemeId={currentThemeId} />
562
+ </DashLayout>,
563
+ );
564
+ });
565
+
566
+ // Save theme
567
+ settingsRoutes.post("/appearance", async (c) => {
568
+ const body = await c.req.json<{ theme: string }>();
569
+ const { settings } = c.var.services;
570
+ const themes = getAvailableThemes(c.var.config);
571
+
572
+ const validTheme = themes.find((t) => t.id === body.theme);
573
+ if (!validTheme) {
574
+ return dsToast("Invalid theme selected.", "error");
575
+ }
576
+
577
+ if (validTheme.id === "default") {
578
+ await settings.remove(SETTINGS_KEYS.THEME);
579
+ } else {
580
+ await settings.set(SETTINGS_KEYS.THEME, validTheme.id);
581
+ }
582
+
583
+ return dsRedirect("/dash/settings/appearance?saved");
584
+ });
585
+
586
+ // Account page
587
+ settingsRoutes.get("/account", async (c) => {
588
+ const siteName = await getSiteName(c);
589
+ const session = await c.var.auth.api.getSession({
590
+ headers: c.req.raw.headers,
591
+ });
592
+ const userName = session?.user?.name ?? "";
593
+ const saved = c.req.query("saved") !== undefined;
594
+
595
+ return c.html(
596
+ <DashLayout
597
+ c={c}
598
+ title="Settings"
599
+ siteName={siteName}
600
+ currentPath="/dash/settings"
601
+ toast={saved ? { message: "Profile saved successfully." } : undefined}
602
+ >
603
+ <AccountContent userName={userName} />
604
+ </DashLayout>,
605
+ );
606
+ });
607
+
608
+ // Save account profile
609
+ settingsRoutes.post("/account", async (c) => {
610
+ const body = await c.req.json<{ userName: string }>();
611
+ const name = body.userName?.trim();
612
+
613
+ if (!name) {
614
+ return dsToast("Name is required.", "error");
615
+ }
616
+
617
+ try {
618
+ await c.var.auth.api.updateUser({
619
+ body: { name },
620
+ headers: c.req.raw.headers,
621
+ });
622
+ } catch {
623
+ return dsToast("Failed to update profile.", "error");
624
+ }
625
+
626
+ return dsToast("Profile saved successfully.");
627
+ });
628
+
295
629
  // Change password
296
630
  settingsRoutes.post("/password", async (c) => {
297
631
  const body = await c.req.json<{
@@ -0,0 +1,226 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { createTestDatabase } from "../../__tests__/helpers/db.js";
3
+ import { createCollectionService } from "../collection.js";
4
+ import { createPostService } from "../post.js";
5
+ import type { Database } from "../../db/index.js";
6
+
7
+ describe("CollectionService", () => {
8
+ let db: Database;
9
+ let collectionService: ReturnType<typeof createCollectionService>;
10
+ let postService: ReturnType<typeof createPostService>;
11
+
12
+ beforeEach(() => {
13
+ const testDb = createTestDatabase();
14
+ db = testDb.db as unknown as Database;
15
+ collectionService = createCollectionService(db);
16
+ postService = createPostService(db);
17
+ });
18
+
19
+ describe("create", () => {
20
+ it("creates a collection with required fields", async () => {
21
+ const collection = await collectionService.create({
22
+ title: "My Collection",
23
+ });
24
+
25
+ expect(collection.id).toBe(1);
26
+ expect(collection.title).toBe("My Collection");
27
+ expect(collection.path).toBeNull();
28
+ expect(collection.description).toBeNull();
29
+ });
30
+
31
+ it("creates a collection with all fields", async () => {
32
+ const collection = await collectionService.create({
33
+ title: "Tech Posts",
34
+ path: "tech",
35
+ description: "Posts about technology",
36
+ });
37
+
38
+ expect(collection.title).toBe("Tech Posts");
39
+ expect(collection.path).toBe("tech");
40
+ expect(collection.description).toBe("Posts about technology");
41
+ });
42
+
43
+ it("sets timestamps", async () => {
44
+ const collection = await collectionService.create({
45
+ title: "Test",
46
+ });
47
+
48
+ expect(collection.createdAt).toBeGreaterThan(0);
49
+ expect(collection.updatedAt).toBeGreaterThan(0);
50
+ });
51
+ });
52
+
53
+ describe("getById", () => {
54
+ it("returns a collection by ID", async () => {
55
+ const created = await collectionService.create({ title: "Test" });
56
+
57
+ const found = await collectionService.getById(created.id);
58
+ expect(found).not.toBeNull();
59
+ expect(found?.title).toBe("Test");
60
+ });
61
+
62
+ it("returns null for non-existent ID", async () => {
63
+ const found = await collectionService.getById(9999);
64
+ expect(found).toBeNull();
65
+ });
66
+ });
67
+
68
+ describe("getByPath", () => {
69
+ it("returns a collection by path", async () => {
70
+ await collectionService.create({ title: "Tech", path: "tech" });
71
+
72
+ const found = await collectionService.getByPath("tech");
73
+ expect(found).not.toBeNull();
74
+ expect(found?.title).toBe("Tech");
75
+ });
76
+
77
+ it("returns null for non-existent path", async () => {
78
+ const found = await collectionService.getByPath("nonexistent");
79
+ expect(found).toBeNull();
80
+ });
81
+ });
82
+
83
+ describe("list", () => {
84
+ it("returns empty array when no collections exist", async () => {
85
+ const list = await collectionService.list();
86
+ expect(list).toEqual([]);
87
+ });
88
+
89
+ it("returns all collections", async () => {
90
+ await collectionService.create({ title: "First" });
91
+ await collectionService.create({ title: "Second" });
92
+ await collectionService.create({ title: "Third" });
93
+
94
+ const list = await collectionService.list();
95
+ expect(list).toHaveLength(3);
96
+ });
97
+ });
98
+
99
+ describe("update", () => {
100
+ it("updates collection title", async () => {
101
+ const collection = await collectionService.create({ title: "Old" });
102
+
103
+ const updated = await collectionService.update(collection.id, {
104
+ title: "New",
105
+ });
106
+
107
+ expect(updated?.title).toBe("New");
108
+ });
109
+
110
+ it("updates collection path", async () => {
111
+ const collection = await collectionService.create({
112
+ title: "Test",
113
+ path: "old-path",
114
+ });
115
+
116
+ const updated = await collectionService.update(collection.id, {
117
+ path: "new-path",
118
+ });
119
+
120
+ expect(updated?.path).toBe("new-path");
121
+ });
122
+
123
+ it("returns null for non-existent collection", async () => {
124
+ const result = await collectionService.update(9999, { title: "X" });
125
+ expect(result).toBeNull();
126
+ });
127
+ });
128
+
129
+ describe("delete", () => {
130
+ it("deletes a collection", async () => {
131
+ const collection = await collectionService.create({ title: "Test" });
132
+
133
+ const result = await collectionService.delete(collection.id);
134
+ expect(result).toBe(true);
135
+
136
+ const found = await collectionService.getById(collection.id);
137
+ expect(found).toBeNull();
138
+ });
139
+
140
+ it("deletes associated post-collection relationships", async () => {
141
+ const collection = await collectionService.create({ title: "Test" });
142
+ const post = await postService.create({
143
+ type: "note",
144
+ content: "test",
145
+ });
146
+
147
+ await collectionService.addPost(collection.id, post.id);
148
+ await collectionService.delete(collection.id);
149
+
150
+ // Post itself should still exist
151
+ expect(await postService.getById(post.id)).not.toBeNull();
152
+ });
153
+
154
+ it("returns false for non-existent collection", async () => {
155
+ const result = await collectionService.delete(9999);
156
+ expect(result).toBe(false);
157
+ });
158
+ });
159
+
160
+ describe("post relationships", () => {
161
+ it("adds a post to a collection", async () => {
162
+ const collection = await collectionService.create({ title: "Test" });
163
+ const post = await postService.create({
164
+ type: "note",
165
+ content: "test",
166
+ });
167
+
168
+ await collectionService.addPost(collection.id, post.id);
169
+
170
+ const posts = await collectionService.getPosts(collection.id);
171
+ expect(posts).toHaveLength(1);
172
+ expect(posts[0]?.id).toBe(post.id);
173
+ });
174
+
175
+ it("adding same post twice is idempotent", async () => {
176
+ const collection = await collectionService.create({ title: "Test" });
177
+ const post = await postService.create({
178
+ type: "note",
179
+ content: "test",
180
+ });
181
+
182
+ await collectionService.addPost(collection.id, post.id);
183
+ await collectionService.addPost(collection.id, post.id);
184
+
185
+ const posts = await collectionService.getPosts(collection.id);
186
+ expect(posts).toHaveLength(1);
187
+ });
188
+
189
+ it("removes a post from a collection", async () => {
190
+ const collection = await collectionService.create({ title: "Test" });
191
+ const post = await postService.create({
192
+ type: "note",
193
+ content: "test",
194
+ });
195
+
196
+ await collectionService.addPost(collection.id, post.id);
197
+ await collectionService.removePost(collection.id, post.id);
198
+
199
+ const posts = await collectionService.getPosts(collection.id);
200
+ expect(posts).toHaveLength(0);
201
+ });
202
+
203
+ it("returns collections for a post", async () => {
204
+ const col1 = await collectionService.create({ title: "Col 1" });
205
+ const col2 = await collectionService.create({ title: "Col 2" });
206
+ const post = await postService.create({
207
+ type: "note",
208
+ content: "test",
209
+ });
210
+
211
+ await collectionService.addPost(col1.id, post.id);
212
+ await collectionService.addPost(col2.id, post.id);
213
+
214
+ const collections = await collectionService.getCollectionsForPost(
215
+ post.id,
216
+ );
217
+ expect(collections).toHaveLength(2);
218
+ });
219
+
220
+ it("getPosts returns empty array for empty collection", async () => {
221
+ const collection = await collectionService.create({ title: "Empty" });
222
+ const posts = await collectionService.getPosts(collection.id);
223
+ expect(posts).toEqual([]);
224
+ });
225
+ });
226
+ });