@jant/core 0.3.2 → 0.3.4

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.
@@ -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
 
@@ -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
+ });