@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
@@ -0,0 +1,139 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { Hono } from "hono";
3
+ import { requireAuth, requireAuthApi } from "../auth.js";
4
+ import type { Bindings } from "../../types.js";
5
+ import type { AppVariables } from "../../app.js";
6
+
7
+ type Env = { Bindings: Bindings; Variables: AppVariables };
8
+
9
+ function createMockAuth(authenticated: boolean) {
10
+ return {
11
+ api: {
12
+ getSession: async () =>
13
+ authenticated
14
+ ? {
15
+ user: { id: "user-1", email: "test@test.com", name: "Test" },
16
+ session: { id: "session-1" },
17
+ }
18
+ : null,
19
+ },
20
+ } as AppVariables["auth"];
21
+ }
22
+
23
+ describe("requireAuth", () => {
24
+ it("allows authenticated requests", async () => {
25
+ const app = new Hono<Env>();
26
+ app.use("*", async (c, next) => {
27
+ c.set("auth", createMockAuth(true));
28
+ await next();
29
+ });
30
+ app.get("/dash", requireAuth(), (c) => c.text("Dashboard"));
31
+
32
+ const res = await app.request("/dash");
33
+ expect(res.status).toBe(200);
34
+ expect(await res.text()).toBe("Dashboard");
35
+ });
36
+
37
+ it("redirects unauthenticated requests to /signin", async () => {
38
+ const app = new Hono<Env>();
39
+ app.use("*", async (c, next) => {
40
+ c.set("auth", createMockAuth(false));
41
+ await next();
42
+ });
43
+ app.get("/dash", requireAuth(), (c) => c.text("Dashboard"));
44
+
45
+ const res = await app.request("/dash", { redirect: "manual" });
46
+ expect(res.status).toBe(302);
47
+ expect(res.headers.get("Location")).toBe("/signin");
48
+ });
49
+
50
+ it("redirects to custom path", async () => {
51
+ const app = new Hono<Env>();
52
+ app.use("*", async (c, next) => {
53
+ c.set("auth", createMockAuth(false));
54
+ await next();
55
+ });
56
+ app.get("/dash", requireAuth("/login"), (c) => c.text("Dashboard"));
57
+
58
+ const res = await app.request("/dash", { redirect: "manual" });
59
+ expect(res.status).toBe(302);
60
+ expect(res.headers.get("Location")).toBe("/login");
61
+ });
62
+
63
+ it("redirects when auth is not configured", async () => {
64
+ const app = new Hono<Env>();
65
+ app.use("*", async (c, next) => {
66
+ // auth not set (undefined)
67
+ await next();
68
+ });
69
+ app.get("/dash", requireAuth(), (c) => c.text("Dashboard"));
70
+
71
+ const res = await app.request("/dash", { redirect: "manual" });
72
+ expect(res.status).toBe(302);
73
+ });
74
+ });
75
+
76
+ describe("requireAuthApi", () => {
77
+ it("allows authenticated requests", async () => {
78
+ const app = new Hono<Env>();
79
+ app.use("*", async (c, next) => {
80
+ c.set("auth", createMockAuth(true));
81
+ await next();
82
+ });
83
+ app.get("/api/data", requireAuthApi(), (c) => c.json({ data: "secret" }));
84
+
85
+ const res = await app.request("/api/data");
86
+ expect(res.status).toBe(200);
87
+
88
+ const body = await res.json();
89
+ expect(body.data).toBe("secret");
90
+ });
91
+
92
+ it("returns 401 for unauthenticated requests", async () => {
93
+ const app = new Hono<Env>();
94
+ app.use("*", async (c, next) => {
95
+ c.set("auth", createMockAuth(false));
96
+ await next();
97
+ });
98
+ app.get("/api/data", requireAuthApi(), (c) => c.json({ data: "secret" }));
99
+
100
+ const res = await app.request("/api/data");
101
+ expect(res.status).toBe(401);
102
+
103
+ const body = await res.json();
104
+ expect(body.error).toBe("Unauthorized");
105
+ });
106
+
107
+ it("returns 500 when auth is not configured", async () => {
108
+ const app = new Hono<Env>();
109
+ app.use("*", async (c, next) => {
110
+ // auth not set (undefined)
111
+ await next();
112
+ });
113
+ app.get("/api/data", requireAuthApi(), (c) => c.json({ data: "secret" }));
114
+
115
+ const res = await app.request("/api/data");
116
+ expect(res.status).toBe(500);
117
+
118
+ const body = await res.json();
119
+ expect(body.error).toBe("Authentication not configured");
120
+ });
121
+
122
+ it("returns 401 when getSession throws", async () => {
123
+ const app = new Hono<Env>();
124
+ app.use("*", async (c, next) => {
125
+ c.set("auth", {
126
+ api: {
127
+ getSession: async () => {
128
+ throw new Error("Session error");
129
+ },
130
+ },
131
+ } as AppVariables["auth"]);
132
+ await next();
133
+ });
134
+ app.get("/api/data", requireAuthApi(), (c) => c.json({ data: "secret" }));
135
+
136
+ const res = await app.request("/api/data");
137
+ expect(res.status).toBe(401);
138
+ });
139
+ });
@@ -0,0 +1,306 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { createTestApp } from "../../../__tests__/helpers/app.js";
3
+ import { postsApiRoutes } from "../posts.js";
4
+ import * as sqid from "../../../lib/sqid.js";
5
+
6
+ describe("Posts API Routes", () => {
7
+ describe("GET /api/posts", () => {
8
+ it("returns empty list when no posts exist", async () => {
9
+ const { app } = createTestApp();
10
+ app.route("/api/posts", postsApiRoutes);
11
+
12
+ const res = await app.request("/api/posts");
13
+ expect(res.status).toBe(200);
14
+
15
+ const body = await res.json();
16
+ expect(body.posts).toEqual([]);
17
+ expect(body.nextCursor).toBeNull();
18
+ });
19
+
20
+ it("returns posts with sqids", async () => {
21
+ const { app, services } = createTestApp();
22
+ app.route("/api/posts", postsApiRoutes);
23
+
24
+ await services.posts.create({
25
+ type: "note",
26
+ content: "Hello world",
27
+ visibility: "featured",
28
+ });
29
+
30
+ const res = await app.request("/api/posts");
31
+ const body = await res.json();
32
+
33
+ expect(body.posts).toHaveLength(1);
34
+ expect(body.posts[0].content).toBe("Hello world");
35
+ expect(body.posts[0].sqid).toBeTruthy();
36
+ });
37
+
38
+ it("filters by visibility", async () => {
39
+ const { app, services } = createTestApp();
40
+ app.route("/api/posts", postsApiRoutes);
41
+
42
+ await services.posts.create({
43
+ type: "note",
44
+ content: "featured",
45
+ visibility: "featured",
46
+ });
47
+ await services.posts.create({
48
+ type: "note",
49
+ content: "draft",
50
+ visibility: "draft",
51
+ });
52
+
53
+ const res = await app.request("/api/posts?visibility=draft");
54
+ const body = await res.json();
55
+
56
+ expect(body.posts).toHaveLength(1);
57
+ expect(body.posts[0].visibility).toBe("draft");
58
+ });
59
+
60
+ it("supports limit parameter", async () => {
61
+ const { app, services } = createTestApp();
62
+ app.route("/api/posts", postsApiRoutes);
63
+
64
+ for (let i = 0; i < 5; i++) {
65
+ await services.posts.create({
66
+ type: "note",
67
+ content: `post ${i}`,
68
+ visibility: "featured",
69
+ });
70
+ }
71
+
72
+ const res = await app.request("/api/posts?limit=2");
73
+ const body = await res.json();
74
+
75
+ expect(body.posts).toHaveLength(2);
76
+ expect(body.nextCursor).toBeTruthy();
77
+ });
78
+ });
79
+
80
+ describe("GET /api/posts/:id", () => {
81
+ it("returns a post by sqid", async () => {
82
+ const { app, services } = createTestApp();
83
+ app.route("/api/posts", postsApiRoutes);
84
+
85
+ const post = await services.posts.create({
86
+ type: "note",
87
+ content: "test post",
88
+ visibility: "featured",
89
+ });
90
+ const id = sqid.encode(post.id);
91
+
92
+ const res = await app.request(`/api/posts/${id}`);
93
+ expect(res.status).toBe(200);
94
+
95
+ const body = await res.json();
96
+ expect(body.content).toBe("test post");
97
+ expect(body.sqid).toBe(id);
98
+ });
99
+
100
+ it("returns 400 for invalid sqid", async () => {
101
+ const { app } = createTestApp();
102
+ app.route("/api/posts", postsApiRoutes);
103
+
104
+ const res = await app.request("/api/posts/!!invalid!!");
105
+ expect(res.status).toBe(400);
106
+ });
107
+
108
+ it("returns 404 for non-existent post", async () => {
109
+ const { app } = createTestApp();
110
+ app.route("/api/posts", postsApiRoutes);
111
+
112
+ const res = await app.request(`/api/posts/${sqid.encode(9999)}`);
113
+ expect(res.status).toBe(404);
114
+ });
115
+ });
116
+
117
+ describe("POST /api/posts", () => {
118
+ it("returns 401 when not authenticated", async () => {
119
+ const { app } = createTestApp({ authenticated: false });
120
+ app.route("/api/posts", postsApiRoutes);
121
+
122
+ const res = await app.request("/api/posts", {
123
+ method: "POST",
124
+ headers: { "Content-Type": "application/json" },
125
+ body: JSON.stringify({
126
+ type: "note",
127
+ content: "test",
128
+ visibility: "quiet",
129
+ }),
130
+ });
131
+
132
+ expect(res.status).toBe(401);
133
+ });
134
+
135
+ it("creates a post when authenticated", async () => {
136
+ const { app } = createTestApp({ authenticated: true });
137
+ app.route("/api/posts", postsApiRoutes);
138
+
139
+ const res = await app.request("/api/posts", {
140
+ method: "POST",
141
+ headers: { "Content-Type": "application/json" },
142
+ body: JSON.stringify({
143
+ type: "note",
144
+ content: "Hello from API",
145
+ visibility: "quiet",
146
+ }),
147
+ });
148
+
149
+ expect(res.status).toBe(201);
150
+
151
+ const body = await res.json();
152
+ expect(body.content).toBe("Hello from API");
153
+ expect(body.sqid).toBeTruthy();
154
+ });
155
+
156
+ it("returns 400 for invalid body", async () => {
157
+ const { app } = createTestApp({ authenticated: true });
158
+ app.route("/api/posts", postsApiRoutes);
159
+
160
+ const res = await app.request("/api/posts", {
161
+ method: "POST",
162
+ headers: { "Content-Type": "application/json" },
163
+ body: JSON.stringify({ type: "invalid-type" }),
164
+ });
165
+
166
+ expect(res.status).toBe(400);
167
+ const body = await res.json();
168
+ expect(body.error).toBe("Validation failed");
169
+ });
170
+
171
+ it("returns 400 for missing required fields", async () => {
172
+ const { app } = createTestApp({ authenticated: true });
173
+ app.route("/api/posts", postsApiRoutes);
174
+
175
+ const res = await app.request("/api/posts", {
176
+ method: "POST",
177
+ headers: { "Content-Type": "application/json" },
178
+ body: JSON.stringify({}),
179
+ });
180
+
181
+ expect(res.status).toBe(400);
182
+ });
183
+ });
184
+
185
+ describe("PUT /api/posts/:id", () => {
186
+ it("returns 401 when not authenticated", async () => {
187
+ const { app, services } = createTestApp({ authenticated: false });
188
+ app.route("/api/posts", postsApiRoutes);
189
+
190
+ const post = await services.posts.create({
191
+ type: "note",
192
+ content: "original",
193
+ });
194
+
195
+ const res = await app.request(`/api/posts/${sqid.encode(post.id)}`, {
196
+ method: "PUT",
197
+ headers: { "Content-Type": "application/json" },
198
+ body: JSON.stringify({ content: "updated" }),
199
+ });
200
+
201
+ expect(res.status).toBe(401);
202
+ });
203
+
204
+ it("updates a post when authenticated", async () => {
205
+ const { app, services } = createTestApp({ authenticated: true });
206
+ app.route("/api/posts", postsApiRoutes);
207
+
208
+ const post = await services.posts.create({
209
+ type: "note",
210
+ content: "original",
211
+ });
212
+
213
+ const res = await app.request(`/api/posts/${sqid.encode(post.id)}`, {
214
+ method: "PUT",
215
+ headers: { "Content-Type": "application/json" },
216
+ body: JSON.stringify({ content: "updated" }),
217
+ });
218
+
219
+ expect(res.status).toBe(200);
220
+ const body = await res.json();
221
+ expect(body.content).toBe("updated");
222
+ });
223
+
224
+ it("returns 404 for non-existent post", async () => {
225
+ const { app } = createTestApp({ authenticated: true });
226
+ app.route("/api/posts", postsApiRoutes);
227
+
228
+ const res = await app.request(`/api/posts/${sqid.encode(9999)}`, {
229
+ method: "PUT",
230
+ headers: { "Content-Type": "application/json" },
231
+ body: JSON.stringify({ content: "test" }),
232
+ });
233
+
234
+ expect(res.status).toBe(404);
235
+ });
236
+
237
+ it("returns 400 for invalid update data", async () => {
238
+ const { app, services } = createTestApp({ authenticated: true });
239
+ app.route("/api/posts", postsApiRoutes);
240
+
241
+ const post = await services.posts.create({
242
+ type: "note",
243
+ content: "test",
244
+ });
245
+
246
+ const res = await app.request(`/api/posts/${sqid.encode(post.id)}`, {
247
+ method: "PUT",
248
+ headers: { "Content-Type": "application/json" },
249
+ body: JSON.stringify({ type: "invalid-type" }),
250
+ });
251
+
252
+ expect(res.status).toBe(400);
253
+ });
254
+ });
255
+
256
+ describe("DELETE /api/posts/:id", () => {
257
+ it("returns 401 when not authenticated", async () => {
258
+ const { app, services } = createTestApp({ authenticated: false });
259
+ app.route("/api/posts", postsApiRoutes);
260
+
261
+ const post = await services.posts.create({
262
+ type: "note",
263
+ content: "test",
264
+ });
265
+
266
+ const res = await app.request(`/api/posts/${sqid.encode(post.id)}`, {
267
+ method: "DELETE",
268
+ });
269
+
270
+ expect(res.status).toBe(401);
271
+ });
272
+
273
+ it("deletes a post when authenticated", async () => {
274
+ const { app, services } = createTestApp({ authenticated: true });
275
+ app.route("/api/posts", postsApiRoutes);
276
+
277
+ const post = await services.posts.create({
278
+ type: "note",
279
+ content: "to be deleted",
280
+ });
281
+
282
+ const res = await app.request(`/api/posts/${sqid.encode(post.id)}`, {
283
+ method: "DELETE",
284
+ });
285
+
286
+ expect(res.status).toBe(200);
287
+ const body = await res.json();
288
+ expect(body.success).toBe(true);
289
+
290
+ // Verify post is deleted
291
+ const found = await services.posts.getById(post.id);
292
+ expect(found).toBeNull();
293
+ });
294
+
295
+ it("returns 404 for non-existent post", async () => {
296
+ const { app } = createTestApp({ authenticated: true });
297
+ app.route("/api/posts", postsApiRoutes);
298
+
299
+ const res = await app.request(`/api/posts/${sqid.encode(9999)}`, {
300
+ method: "DELETE",
301
+ });
302
+
303
+ expect(res.status).toBe(404);
304
+ });
305
+ });
306
+ });
@@ -0,0 +1,77 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { createTestApp } from "../../../__tests__/helpers/app.js";
3
+ import { searchApiRoutes } from "../search.js";
4
+
5
+ describe("Search API Routes", () => {
6
+ it("returns 400 when query is missing", async () => {
7
+ const { app } = createTestApp({ fts: true });
8
+ app.route("/api/search", searchApiRoutes);
9
+
10
+ const res = await app.request("/api/search");
11
+ expect(res.status).toBe(400);
12
+
13
+ const body = await res.json();
14
+ expect(body.error).toContain("'q' is required");
15
+ });
16
+
17
+ it("returns 400 for empty query", async () => {
18
+ const { app } = createTestApp({ fts: true });
19
+ app.route("/api/search", searchApiRoutes);
20
+
21
+ const res = await app.request("/api/search?q=");
22
+ expect(res.status).toBe(400);
23
+ });
24
+
25
+ it("returns 400 for query over 200 characters", async () => {
26
+ const { app } = createTestApp({ fts: true });
27
+ app.route("/api/search", searchApiRoutes);
28
+
29
+ const longQuery = "a".repeat(201);
30
+ const res = await app.request(`/api/search?q=${longQuery}`);
31
+ expect(res.status).toBe(400);
32
+
33
+ const body = await res.json();
34
+ expect(body.error).toBe("Query too long");
35
+ });
36
+
37
+ it("returns search results for valid query", async () => {
38
+ const { app, services } = createTestApp({ fts: true });
39
+ app.route("/api/search", searchApiRoutes);
40
+
41
+ await services.posts.create({
42
+ type: "note",
43
+ content: "Testing search functionality in jant",
44
+ visibility: "featured",
45
+ });
46
+
47
+ const res = await app.request("/api/search?q=jant");
48
+ expect(res.status).toBe(200);
49
+
50
+ const body = await res.json();
51
+ expect(body.query).toBe("jant");
52
+ expect(body.results.length).toBeGreaterThanOrEqual(1);
53
+ expect(body.count).toBeGreaterThanOrEqual(1);
54
+ expect(body.results[0].url).toMatch(/^\/p\//);
55
+ });
56
+
57
+ it("returns empty results for non-matching query", async () => {
58
+ const { app } = createTestApp({ fts: true });
59
+ app.route("/api/search", searchApiRoutes);
60
+
61
+ const res = await app.request("/api/search?q=zznonexistentzzz");
62
+ expect(res.status).toBe(200);
63
+
64
+ const body = await res.json();
65
+ expect(body.results).toEqual([]);
66
+ expect(body.count).toBe(0);
67
+ });
68
+
69
+ it("does not require authentication", async () => {
70
+ const { app } = createTestApp({ authenticated: false, fts: true });
71
+ app.route("/api/search", searchApiRoutes);
72
+
73
+ const res = await app.request("/api/search?q=test");
74
+ // Should not return 401
75
+ expect(res.status).not.toBe(401);
76
+ });
77
+ });
@@ -34,7 +34,9 @@ postsApiRoutes.get("/", async (c) => {
34
34
  })),
35
35
 
36
36
  nextCursor:
37
- posts.length === limit ? sqid.encode(posts[posts.length - 1]!.id) : null,
37
+ posts.length === limit
38
+ ? sqid.encode(posts[posts.length - 1]?.id ?? 0)
39
+ : null,
38
40
  });
39
41
  });
40
42