@jant/core 0.3.2 → 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.
- package/dist/routes/api/posts.js +1 -1
- package/package.json +10 -4
- package/src/__tests__/helpers/app.ts +97 -0
- package/src/__tests__/helpers/db.ts +85 -0
- package/src/lib/__tests__/constants.test.ts +44 -0
- package/src/lib/__tests__/markdown.test.ts +133 -0
- package/src/lib/__tests__/schemas.test.ts +220 -0
- package/src/lib/__tests__/sqid.test.ts +65 -0
- package/src/lib/__tests__/sse.test.ts +86 -0
- package/src/lib/__tests__/time.test.ts +112 -0
- package/src/lib/__tests__/url.test.ts +138 -0
- package/src/middleware/__tests__/auth.test.ts +139 -0
- package/src/routes/api/__tests__/posts.test.ts +306 -0
- package/src/routes/api/__tests__/search.test.ts +77 -0
- package/src/routes/api/posts.ts +3 -1
- package/src/services/__tests__/collection.test.ts +226 -0
- package/src/services/__tests__/media.test.ts +134 -0
- package/src/services/__tests__/post.test.ts +636 -0
- package/src/services/__tests__/redirect.test.ts +110 -0
- package/src/services/__tests__/search.test.ts +143 -0
- package/src/services/__tests__/settings.test.ts +110 -0
package/dist/routes/api/posts.js
CHANGED
|
@@ -27,7 +27,7 @@ postsApiRoutes.get("/", async (c)=>{
|
|
|
27
27
|
...p,
|
|
28
28
|
sqid: sqid.encode(p.id)
|
|
29
29
|
})),
|
|
30
|
-
nextCursor: posts.length === limit ? sqid.encode(posts[posts.length - 1]
|
|
30
|
+
nextCursor: posts.length === limit ? sqid.encode(posts[posts.length - 1]?.id ?? 0) : null
|
|
31
31
|
});
|
|
32
32
|
});
|
|
33
33
|
// Get single post
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jant/core",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "A modern, open-source microblogging platform built on Cloudflare Workers",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -53,12 +53,15 @@
|
|
|
53
53
|
"@lingui/swc-plugin": "^5.10.1",
|
|
54
54
|
"@swc/cli": "^0.6.0",
|
|
55
55
|
"@swc/core": "^1.15.11",
|
|
56
|
+
"@types/better-sqlite3": "^7.6.13",
|
|
56
57
|
"@types/node": "^25.1.0",
|
|
58
|
+
"better-sqlite3": "^12.6.2",
|
|
57
59
|
"drizzle-kit": "^0.31.8",
|
|
58
60
|
"glob": "^13.0.0",
|
|
59
61
|
"tailwindcss": "^4.1.18",
|
|
60
62
|
"tsx": "^4.21.0",
|
|
61
|
-
"typescript": "^5.9.3"
|
|
63
|
+
"typescript": "^5.9.3",
|
|
64
|
+
"vitest": "^4.0.18"
|
|
62
65
|
},
|
|
63
66
|
"repository": {
|
|
64
67
|
"type": "git",
|
|
@@ -85,12 +88,15 @@
|
|
|
85
88
|
},
|
|
86
89
|
"scripts": {
|
|
87
90
|
"build": "pnpm build:lib",
|
|
88
|
-
"build:lib": "swc src -d dist --strip-leading-paths && pnpm build:types",
|
|
91
|
+
"build:lib": "swc src -d dist --strip-leading-paths --ignore '**/__tests__/**' && pnpm build:types",
|
|
89
92
|
"build:types": "tsc -p tsconfig.build.json",
|
|
90
93
|
"typecheck": "tsc --noEmit && tsc -p tsconfig.client.json",
|
|
91
94
|
"db:generate": "drizzle-kit generate",
|
|
92
95
|
"i18n:extract": "lingui extract",
|
|
93
96
|
"i18n:compile": "lingui compile --typescript",
|
|
94
|
-
"i18n:build": "pnpm i18n:extract && pnpm i18n:compile"
|
|
97
|
+
"i18n:build": "pnpm i18n:extract && pnpm i18n:compile",
|
|
98
|
+
"test": "vitest run",
|
|
99
|
+
"test:watch": "vitest",
|
|
100
|
+
"test:coverage": "vitest run --coverage"
|
|
95
101
|
}
|
|
96
102
|
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test App Helper
|
|
3
|
+
*
|
|
4
|
+
* Creates a minimal Hono app with services wired up for route testing.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Hono } from "hono";
|
|
8
|
+
import type { Bindings } from "../../types.js";
|
|
9
|
+
import type { AppVariables } from "../../app.js";
|
|
10
|
+
import { createTestDatabase } from "./db.js";
|
|
11
|
+
import { createPostService } from "../../services/post.js";
|
|
12
|
+
import { createSettingsService } from "../../services/settings.js";
|
|
13
|
+
import { createRedirectService } from "../../services/redirect.js";
|
|
14
|
+
import { createMediaService } from "../../services/media.js";
|
|
15
|
+
import { createCollectionService } from "../../services/collection.js";
|
|
16
|
+
import { createSearchService } from "../../services/search.js";
|
|
17
|
+
import type { Database } from "../../db/index.js";
|
|
18
|
+
import type BetterSqlite3 from "better-sqlite3";
|
|
19
|
+
|
|
20
|
+
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
21
|
+
|
|
22
|
+
interface TestAppOptions {
|
|
23
|
+
/** If true, all requests are treated as authenticated */
|
|
24
|
+
authenticated?: boolean;
|
|
25
|
+
/** Enable FTS for search tests */
|
|
26
|
+
fts?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Creates a test Hono app with real services backed by in-memory SQLite.
|
|
31
|
+
* Returns the app and service instances for assertions.
|
|
32
|
+
*/
|
|
33
|
+
export function createTestApp(options: TestAppOptions = {}) {
|
|
34
|
+
const testDb = createTestDatabase({ fts: options.fts });
|
|
35
|
+
const db = testDb.db as unknown as Database;
|
|
36
|
+
const sqlite = testDb.sqlite;
|
|
37
|
+
|
|
38
|
+
// Create a mock D1 for search service
|
|
39
|
+
const mockD1 = createMockD1(sqlite);
|
|
40
|
+
|
|
41
|
+
const services = {
|
|
42
|
+
posts: createPostService(db),
|
|
43
|
+
settings: createSettingsService(db),
|
|
44
|
+
redirects: createRedirectService(db),
|
|
45
|
+
media: createMediaService(db),
|
|
46
|
+
collections: createCollectionService(db),
|
|
47
|
+
search: createSearchService(mockD1),
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const app = new Hono<Env>();
|
|
51
|
+
|
|
52
|
+
// Inject services middleware
|
|
53
|
+
app.use("*", async (c, next) => {
|
|
54
|
+
c.set("services", services as AppVariables["services"]);
|
|
55
|
+
c.set("config", {});
|
|
56
|
+
|
|
57
|
+
if (options.authenticated) {
|
|
58
|
+
// Mock auth that always returns a session
|
|
59
|
+
c.set("auth", {
|
|
60
|
+
api: {
|
|
61
|
+
getSession: async () => ({
|
|
62
|
+
user: { id: "test-user", email: "test@test.com", name: "Test" },
|
|
63
|
+
session: { id: "test-session" },
|
|
64
|
+
}),
|
|
65
|
+
},
|
|
66
|
+
} as AppVariables["auth"]);
|
|
67
|
+
} else {
|
|
68
|
+
c.set("auth", {
|
|
69
|
+
api: {
|
|
70
|
+
getSession: async () => null,
|
|
71
|
+
},
|
|
72
|
+
} as AppVariables["auth"]);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
await next();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
return { app, services, db, sqlite };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function createMockD1(sqliteDb: BetterSqlite3.Database) {
|
|
82
|
+
return {
|
|
83
|
+
prepare(query: string) {
|
|
84
|
+
return {
|
|
85
|
+
bind(...params: unknown[]) {
|
|
86
|
+
return {
|
|
87
|
+
async all<T>() {
|
|
88
|
+
const stmt = sqliteDb.prepare(query);
|
|
89
|
+
const rows = stmt.all(...(params as never[])) as T[];
|
|
90
|
+
return { results: rows };
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
},
|
|
96
|
+
} as unknown as D1Database;
|
|
97
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test Database Helper
|
|
3
|
+
*
|
|
4
|
+
* Creates an in-memory SQLite database with all migrations applied.
|
|
5
|
+
* Used for service integration tests.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import Database from "better-sqlite3";
|
|
9
|
+
import { drizzle } from "drizzle-orm/better-sqlite3";
|
|
10
|
+
import * as schema from "../../db/schema.js";
|
|
11
|
+
import { readFileSync } from "fs";
|
|
12
|
+
import { resolve } from "path";
|
|
13
|
+
|
|
14
|
+
const MIGRATIONS_DIR = resolve(import.meta.dirname, "../../db/migrations");
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Creates a fresh in-memory SQLite database with all migrations applied.
|
|
18
|
+
* Each call returns an isolated database instance for test isolation.
|
|
19
|
+
*
|
|
20
|
+
* @param options.fts - Whether to apply FTS5 migration (default: false).
|
|
21
|
+
* The trigram tokenizer used in production may not be available in all
|
|
22
|
+
* better-sqlite3 builds, so FTS is opt-in for tests that need it.
|
|
23
|
+
*/
|
|
24
|
+
export function createTestDatabase(options?: { fts?: boolean }) {
|
|
25
|
+
const sqlite = new Database(":memory:");
|
|
26
|
+
|
|
27
|
+
// Enable WAL mode for better performance
|
|
28
|
+
sqlite.pragma("journal_mode = WAL");
|
|
29
|
+
sqlite.pragma("foreign_keys = ON");
|
|
30
|
+
|
|
31
|
+
// Apply base schema migration
|
|
32
|
+
const migration0 = readFileSync(
|
|
33
|
+
resolve(MIGRATIONS_DIR, "0000_square_wallflower.sql"),
|
|
34
|
+
"utf-8",
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
// Drizzle migrations use --> statement-breakpoint as separator
|
|
38
|
+
for (const sql of migration0.split("--> statement-breakpoint")) {
|
|
39
|
+
const trimmed = sql.trim();
|
|
40
|
+
if (trimmed) sqlite.exec(trimmed);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Optionally apply FTS5 migration (with fallback tokenizer)
|
|
44
|
+
if (options?.fts) {
|
|
45
|
+
try {
|
|
46
|
+
const migration1 = readFileSync(
|
|
47
|
+
resolve(MIGRATIONS_DIR, "0001_add_search_fts.sql"),
|
|
48
|
+
"utf-8",
|
|
49
|
+
);
|
|
50
|
+
sqlite.exec(migration1);
|
|
51
|
+
} catch {
|
|
52
|
+
// Fallback: create FTS table with default tokenizer if trigram not available
|
|
53
|
+
sqlite.exec(`
|
|
54
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS posts_fts USING fts5(
|
|
55
|
+
title,
|
|
56
|
+
content,
|
|
57
|
+
content='posts',
|
|
58
|
+
content_rowid='id'
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
CREATE TRIGGER IF NOT EXISTS posts_fts_insert AFTER INSERT ON posts
|
|
62
|
+
WHEN NEW.deleted_at IS NULL
|
|
63
|
+
BEGIN
|
|
64
|
+
INSERT INTO posts_fts(rowid, title, content)
|
|
65
|
+
VALUES (NEW.id, COALESCE(NEW.title, ''), COALESCE(NEW.content, ''));
|
|
66
|
+
END;
|
|
67
|
+
|
|
68
|
+
CREATE TRIGGER IF NOT EXISTS posts_fts_update AFTER UPDATE ON posts BEGIN
|
|
69
|
+
DELETE FROM posts_fts WHERE rowid = OLD.id;
|
|
70
|
+
INSERT INTO posts_fts(rowid, title, content)
|
|
71
|
+
SELECT NEW.id, COALESCE(NEW.title, ''), COALESCE(NEW.content, '')
|
|
72
|
+
WHERE NEW.deleted_at IS NULL;
|
|
73
|
+
END;
|
|
74
|
+
|
|
75
|
+
CREATE TRIGGER IF NOT EXISTS posts_fts_delete AFTER DELETE ON posts BEGIN
|
|
76
|
+
DELETE FROM posts_fts WHERE rowid = OLD.id;
|
|
77
|
+
END;
|
|
78
|
+
`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const db = drizzle(sqlite, { schema });
|
|
83
|
+
|
|
84
|
+
return { db, sqlite };
|
|
85
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { RESERVED_PATHS, isReservedPath } from "../constants.js";
|
|
3
|
+
|
|
4
|
+
describe("RESERVED_PATHS", () => {
|
|
5
|
+
it("contains expected critical paths", () => {
|
|
6
|
+
expect(RESERVED_PATHS).toContain("dash");
|
|
7
|
+
expect(RESERVED_PATHS).toContain("api");
|
|
8
|
+
expect(RESERVED_PATHS).toContain("feed");
|
|
9
|
+
expect(RESERVED_PATHS).toContain("signin");
|
|
10
|
+
expect(RESERVED_PATHS).toContain("search");
|
|
11
|
+
expect(RESERVED_PATHS).toContain("p");
|
|
12
|
+
expect(RESERVED_PATHS).toContain("c");
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("isReservedPath", () => {
|
|
17
|
+
it("returns true for reserved paths", () => {
|
|
18
|
+
expect(isReservedPath("dash")).toBe(true);
|
|
19
|
+
expect(isReservedPath("api")).toBe(true);
|
|
20
|
+
expect(isReservedPath("feed")).toBe(true);
|
|
21
|
+
expect(isReservedPath("signin")).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("checks only the first segment", () => {
|
|
25
|
+
expect(isReservedPath("dash/settings")).toBe(true);
|
|
26
|
+
expect(isReservedPath("api/posts")).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("is case-insensitive", () => {
|
|
30
|
+
expect(isReservedPath("DASH")).toBe(true);
|
|
31
|
+
expect(isReservedPath("Api")).toBe(true);
|
|
32
|
+
expect(isReservedPath("FEED")).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("returns false for non-reserved paths", () => {
|
|
36
|
+
expect(isReservedPath("about")).toBe(false);
|
|
37
|
+
expect(isReservedPath("contact")).toBe(false);
|
|
38
|
+
expect(isReservedPath("my-custom-page")).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("returns false for empty string", () => {
|
|
42
|
+
expect(isReservedPath("")).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { render, toPlainText, extractTitle } from "../markdown.js";
|
|
3
|
+
|
|
4
|
+
describe("render", () => {
|
|
5
|
+
it("renders a heading", () => {
|
|
6
|
+
const html = render("# Hello");
|
|
7
|
+
expect(html).toContain("<h1>");
|
|
8
|
+
expect(html).toContain("Hello");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("renders bold text", () => {
|
|
12
|
+
const html = render("This is **bold** text.");
|
|
13
|
+
expect(html).toContain("<strong>bold</strong>");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("renders italic text", () => {
|
|
17
|
+
const html = render("This is *italic* text.");
|
|
18
|
+
expect(html).toContain("<em>italic</em>");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("renders links", () => {
|
|
22
|
+
const html = render("[link](https://example.com)");
|
|
23
|
+
expect(html).toContain('href="https://example.com"');
|
|
24
|
+
expect(html).toContain(">link</a>");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("renders code blocks", () => {
|
|
28
|
+
const html = render("```\nconst x = 1;\n```");
|
|
29
|
+
expect(html).toContain("<code>");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("renders inline code", () => {
|
|
33
|
+
const html = render("Use `console.log()` here.");
|
|
34
|
+
expect(html).toContain("<code>console.log()</code>");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("supports GFM line breaks", () => {
|
|
38
|
+
const html = render("Line 1\nLine 2");
|
|
39
|
+
expect(html).toContain("<br>");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("returns a string", () => {
|
|
43
|
+
expect(typeof render("test")).toBe("string");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("handles empty string", () => {
|
|
47
|
+
expect(render("")).toBe("");
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("toPlainText", () => {
|
|
52
|
+
it("removes headers", () => {
|
|
53
|
+
expect(toPlainText("## Hello")).toBe("Hello");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("removes bold syntax", () => {
|
|
57
|
+
expect(toPlainText("This is **bold** text")).toBe("This is bold text");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("removes italic syntax", () => {
|
|
61
|
+
expect(toPlainText("This is *italic* text")).toBe("This is italic text");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("extracts link text, removes URLs", () => {
|
|
65
|
+
expect(toPlainText("[a link](https://example.com)")).toBe("a link");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("removes images (note: link regex runs first, leaving ! prefix)", () => {
|
|
69
|
+
// Known behavior: the link regex \[(.+?)\]\(.+?\) captures [alt](url) before
|
|
70
|
+
// the image regex !\[.*?\]\(.+?\) can match, leaving the "!" prefix
|
|
71
|
+
expect(toPlainText("")).toBe("!alt");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("removes blockquotes", () => {
|
|
75
|
+
expect(toPlainText("> quoted text")).toBe("quoted text");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("replaces newlines with spaces", () => {
|
|
79
|
+
const result = toPlainText("Line 1\nLine 2\nLine 3");
|
|
80
|
+
expect(result).toBe("Line 1 Line 2 Line 3");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("handles complex markdown", () => {
|
|
84
|
+
const md = "## Hello\n\nThis is **bold** and [a link](url).";
|
|
85
|
+
const result = toPlainText(md);
|
|
86
|
+
expect(result).toBe("Hello This is bold and a link.");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("handles empty string", () => {
|
|
90
|
+
expect(toPlainText("")).toBe("");
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("extractTitle", () => {
|
|
95
|
+
it("extracts first sentence", () => {
|
|
96
|
+
expect(extractTitle("This is the first sentence. And another one.")).toBe(
|
|
97
|
+
"This is the first sentence",
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("extracts text before exclamation mark", () => {
|
|
102
|
+
expect(extractTitle("Hello world! More text here.")).toBe("Hello world");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("extracts text before question mark", () => {
|
|
106
|
+
expect(extractTitle("What is this? Some answer.")).toBe("What is this");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("truncates long text with ellipsis", () => {
|
|
110
|
+
const long = "A".repeat(200);
|
|
111
|
+
const result = extractTitle(long, 50);
|
|
112
|
+
expect(result.length).toBe(53); // 50 + "..."
|
|
113
|
+
expect(result.endsWith("...")).toBe(true);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("returns full first sentence if under maxLength", () => {
|
|
117
|
+
expect(extractTitle("Short sentence.", 120)).toBe("Short sentence");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("uses default maxLength of 120", () => {
|
|
121
|
+
const long = "A".repeat(200) + ".";
|
|
122
|
+
const result = extractTitle(long);
|
|
123
|
+
expect(result.length).toBe(123); // 120 + "..."
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("strips markdown before extracting", () => {
|
|
127
|
+
expect(extractTitle("## Hello world. More text.")).toBe("Hello world");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("handles empty string", () => {
|
|
131
|
+
expect(extractTitle("")).toBe("");
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
PostTypeSchema,
|
|
4
|
+
VisibilitySchema,
|
|
5
|
+
RedirectTypeSchema,
|
|
6
|
+
CreatePostSchema,
|
|
7
|
+
UpdatePostSchema,
|
|
8
|
+
parseFormData,
|
|
9
|
+
parseFormDataOptional,
|
|
10
|
+
} from "../schemas.js";
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
import { POST_TYPES, VISIBILITY_LEVELS } from "../../types.js";
|
|
13
|
+
|
|
14
|
+
describe("PostTypeSchema", () => {
|
|
15
|
+
it("accepts all valid post types", () => {
|
|
16
|
+
for (const type of POST_TYPES) {
|
|
17
|
+
expect(PostTypeSchema.parse(type)).toBe(type);
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("rejects invalid post types", () => {
|
|
22
|
+
expect(() => PostTypeSchema.parse("invalid")).toThrow();
|
|
23
|
+
expect(() => PostTypeSchema.parse("")).toThrow();
|
|
24
|
+
expect(() => PostTypeSchema.parse(123)).toThrow();
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("VisibilitySchema", () => {
|
|
29
|
+
it("accepts all valid visibility levels", () => {
|
|
30
|
+
for (const level of VISIBILITY_LEVELS) {
|
|
31
|
+
expect(VisibilitySchema.parse(level)).toBe(level);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("rejects invalid visibility levels", () => {
|
|
36
|
+
expect(() => VisibilitySchema.parse("public")).toThrow();
|
|
37
|
+
expect(() => VisibilitySchema.parse("private")).toThrow();
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("RedirectTypeSchema", () => {
|
|
42
|
+
it("accepts 301 and 302 as strings", () => {
|
|
43
|
+
expect(RedirectTypeSchema.parse("301")).toBe("301");
|
|
44
|
+
expect(RedirectTypeSchema.parse("302")).toBe("302");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("rejects other values", () => {
|
|
48
|
+
expect(() => RedirectTypeSchema.parse("200")).toThrow();
|
|
49
|
+
expect(() => RedirectTypeSchema.parse("404")).toThrow();
|
|
50
|
+
expect(() => RedirectTypeSchema.parse(301)).toThrow();
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("CreatePostSchema", () => {
|
|
55
|
+
const validPost = {
|
|
56
|
+
type: "note",
|
|
57
|
+
content: "Hello world",
|
|
58
|
+
visibility: "quiet",
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
it("accepts a valid post with required fields", () => {
|
|
62
|
+
const result = CreatePostSchema.parse(validPost);
|
|
63
|
+
expect(result.type).toBe("note");
|
|
64
|
+
expect(result.content).toBe("Hello world");
|
|
65
|
+
expect(result.visibility).toBe("quiet");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("accepts all post types", () => {
|
|
69
|
+
for (const type of POST_TYPES) {
|
|
70
|
+
expect(() =>
|
|
71
|
+
CreatePostSchema.parse({ ...validPost, type }),
|
|
72
|
+
).not.toThrow();
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("accepts optional title", () => {
|
|
77
|
+
const result = CreatePostSchema.parse({
|
|
78
|
+
...validPost,
|
|
79
|
+
title: "My Post",
|
|
80
|
+
});
|
|
81
|
+
expect(result.title).toBe("My Post");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("accepts valid path format", () => {
|
|
85
|
+
const result = CreatePostSchema.parse({
|
|
86
|
+
...validPost,
|
|
87
|
+
path: "my-post-slug",
|
|
88
|
+
});
|
|
89
|
+
expect(result.path).toBe("my-post-slug");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("accepts empty path", () => {
|
|
93
|
+
const result = CreatePostSchema.parse({ ...validPost, path: "" });
|
|
94
|
+
expect(result.path).toBe("");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("rejects invalid path format (uppercase)", () => {
|
|
98
|
+
expect(() =>
|
|
99
|
+
CreatePostSchema.parse({ ...validPost, path: "MyPost" }),
|
|
100
|
+
).toThrow();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("rejects invalid path format (special chars)", () => {
|
|
104
|
+
expect(() =>
|
|
105
|
+
CreatePostSchema.parse({ ...validPost, path: "my post!" }),
|
|
106
|
+
).toThrow();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("accepts valid source URL", () => {
|
|
110
|
+
const result = CreatePostSchema.parse({
|
|
111
|
+
...validPost,
|
|
112
|
+
sourceUrl: "https://example.com",
|
|
113
|
+
});
|
|
114
|
+
expect(result.sourceUrl).toBe("https://example.com");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("accepts empty source URL", () => {
|
|
118
|
+
const result = CreatePostSchema.parse({ ...validPost, sourceUrl: "" });
|
|
119
|
+
expect(result.sourceUrl).toBe("");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("rejects invalid source URL", () => {
|
|
123
|
+
expect(() =>
|
|
124
|
+
CreatePostSchema.parse({ ...validPost, sourceUrl: "not-a-url" }),
|
|
125
|
+
).toThrow();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("accepts optional publishedAt as positive integer", () => {
|
|
129
|
+
const result = CreatePostSchema.parse({
|
|
130
|
+
...validPost,
|
|
131
|
+
publishedAt: 1706745600,
|
|
132
|
+
});
|
|
133
|
+
expect(result.publishedAt).toBe(1706745600);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("rejects negative publishedAt", () => {
|
|
137
|
+
expect(() =>
|
|
138
|
+
CreatePostSchema.parse({ ...validPost, publishedAt: -1 }),
|
|
139
|
+
).toThrow();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("rejects non-integer publishedAt", () => {
|
|
143
|
+
expect(() =>
|
|
144
|
+
CreatePostSchema.parse({ ...validPost, publishedAt: 1.5 }),
|
|
145
|
+
).toThrow();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("rejects missing required fields", () => {
|
|
149
|
+
expect(() => CreatePostSchema.parse({})).toThrow();
|
|
150
|
+
expect(() => CreatePostSchema.parse({ type: "note" })).toThrow();
|
|
151
|
+
expect(() => CreatePostSchema.parse({ content: "hello" })).toThrow();
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe("UpdatePostSchema", () => {
|
|
156
|
+
it("accepts empty object (all fields optional)", () => {
|
|
157
|
+
const result = UpdatePostSchema.parse({});
|
|
158
|
+
expect(result).toEqual({});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("accepts partial updates", () => {
|
|
162
|
+
const result = UpdatePostSchema.parse({ title: "New Title" });
|
|
163
|
+
expect(result.title).toBe("New Title");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("accepts only type", () => {
|
|
167
|
+
const result = UpdatePostSchema.parse({ type: "article" });
|
|
168
|
+
expect(result.type).toBe("article");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("still validates field types", () => {
|
|
172
|
+
expect(() => UpdatePostSchema.parse({ type: "invalid" })).toThrow();
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe("parseFormData", () => {
|
|
177
|
+
it("parses a valid form field", () => {
|
|
178
|
+
const form = new FormData();
|
|
179
|
+
form.set("name", "hello");
|
|
180
|
+
expect(parseFormData(form, "name", z.string())).toBe("hello");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("throws for missing required field", () => {
|
|
184
|
+
const form = new FormData();
|
|
185
|
+
expect(() => parseFormData(form, "missing", z.string())).toThrow(
|
|
186
|
+
"Missing required field: missing",
|
|
187
|
+
);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("throws for invalid value", () => {
|
|
191
|
+
const form = new FormData();
|
|
192
|
+
form.set("type", "invalid-type");
|
|
193
|
+
expect(() => parseFormData(form, "type", PostTypeSchema)).toThrow();
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe("parseFormDataOptional", () => {
|
|
198
|
+
it("returns parsed value when present", () => {
|
|
199
|
+
const form = new FormData();
|
|
200
|
+
form.set("name", "hello");
|
|
201
|
+
expect(parseFormDataOptional(form, "name", z.string())).toBe("hello");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("returns undefined when field is missing", () => {
|
|
205
|
+
const form = new FormData();
|
|
206
|
+
expect(parseFormDataOptional(form, "missing", z.string())).toBeUndefined();
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("returns undefined when field is empty string", () => {
|
|
210
|
+
const form = new FormData();
|
|
211
|
+
form.set("name", "");
|
|
212
|
+
expect(parseFormDataOptional(form, "name", z.string())).toBeUndefined();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("throws for invalid value", () => {
|
|
216
|
+
const form = new FormData();
|
|
217
|
+
form.set("type", "invalid");
|
|
218
|
+
expect(() => parseFormDataOptional(form, "type", PostTypeSchema)).toThrow();
|
|
219
|
+
});
|
|
220
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { encode, decode, isValidSqid } from "../sqid.js";
|
|
3
|
+
|
|
4
|
+
describe("encode", () => {
|
|
5
|
+
it("encodes a numeric ID to a string", () => {
|
|
6
|
+
const result = encode(1);
|
|
7
|
+
expect(typeof result).toBe("string");
|
|
8
|
+
expect(result.length).toBeGreaterThanOrEqual(4);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("produces minimum 4-character strings", () => {
|
|
12
|
+
expect(encode(0).length).toBeGreaterThanOrEqual(4);
|
|
13
|
+
expect(encode(1).length).toBeGreaterThanOrEqual(4);
|
|
14
|
+
expect(encode(100).length).toBeGreaterThanOrEqual(4);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("produces different strings for different IDs", () => {
|
|
18
|
+
const a = encode(1);
|
|
19
|
+
const b = encode(2);
|
|
20
|
+
const c = encode(100);
|
|
21
|
+
expect(a).not.toBe(b);
|
|
22
|
+
expect(b).not.toBe(c);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("produces consistent results for the same ID", () => {
|
|
26
|
+
expect(encode(42)).toBe(encode(42));
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("decode", () => {
|
|
31
|
+
it("decodes an encoded string back to the original ID", () => {
|
|
32
|
+
for (const id of [0, 1, 42, 100, 999, 10000]) {
|
|
33
|
+
const encoded = encode(id);
|
|
34
|
+
expect(decode(encoded)).toBe(id);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("returns null for empty string", () => {
|
|
39
|
+
expect(decode("")).toBe(null);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("handles round-trip encoding", () => {
|
|
43
|
+
const original = 12345;
|
|
44
|
+
const sqid = encode(original);
|
|
45
|
+
const decoded = decode(sqid);
|
|
46
|
+
expect(decoded).toBe(original);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("isValidSqid", () => {
|
|
51
|
+
it("returns true for valid encoded sqids", () => {
|
|
52
|
+
const sqid = encode(1);
|
|
53
|
+
expect(isValidSqid(sqid)).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("returns true for various valid sqids", () => {
|
|
57
|
+
for (const id of [0, 1, 100, 999]) {
|
|
58
|
+
expect(isValidSqid(encode(id))).toBe(true);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("returns false for empty string", () => {
|
|
63
|
+
expect(isValidSqid("")).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
});
|