@hiai-gg/hiai-docs 0.0.1
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/.all-contributorsrc +18 -0
- package/.claude/settings.local.json +61 -0
- package/.dockerignore +113 -0
- package/.env.example +68 -0
- package/.github/FUNDING.yml +5 -0
- package/.github/ISSUE_TEMPLATE/bug_report.md +74 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +78 -0
- package/.github/dependabot.yml +136 -0
- package/.github/pull_request_template.md +96 -0
- package/.github/workflows/ci.yml +283 -0
- package/AGENTS.md +237 -0
- package/CODE_OF_CONDUCT.md +134 -0
- package/CONTRIBUTING.md +77 -0
- package/Caddyfile +50 -0
- package/Dockerfile.backend +60 -0
- package/LICENSE +21 -0
- package/README.md +284 -0
- package/RELEASE_CHECKLIST.md +34 -0
- package/SECURITY.md +60 -0
- package/backend/package.json +43 -0
- package/backend/src/__tests__/auth-helpers.test.ts +51 -0
- package/backend/src/__tests__/chunker.test.ts +65 -0
- package/backend/src/__tests__/config.test.ts +91 -0
- package/backend/src/__tests__/csrf.test.ts +91 -0
- package/backend/src/__tests__/embedding.test.ts +48 -0
- package/backend/src/__tests__/rate-limit.test.ts +46 -0
- package/backend/src/__tests__/routes.test.ts +38 -0
- package/backend/src/__tests__/schema.test.ts +31 -0
- package/backend/src/__tests__/validation.test.ts +556 -0
- package/backend/src/api/middleware/auth.ts +56 -0
- package/backend/src/api/middleware/csrf.ts +91 -0
- package/backend/src/api/middleware/rate-limit.ts +77 -0
- package/backend/src/api/middleware/webhook-verify.ts +22 -0
- package/backend/src/api/routes/attachments.ts +280 -0
- package/backend/src/api/routes/auth.ts +52 -0
- package/backend/src/api/routes/collaboration.ts +121 -0
- package/backend/src/api/routes/documents.ts +664 -0
- package/backend/src/api/routes/folders.ts +226 -0
- package/backend/src/api/routes/search.ts +354 -0
- package/backend/src/api/routes/share.ts +512 -0
- package/backend/src/api/routes/tags.ts +247 -0
- package/backend/src/api/routes/versions.ts +99 -0
- package/backend/src/api/routes/webhooks.ts +43 -0
- package/backend/src/embedding/chunker.ts +74 -0
- package/backend/src/embedding/index.ts +117 -0
- package/backend/src/embedding/providers/ollama.ts +63 -0
- package/backend/src/embedding/providers/openrouter.ts +71 -0
- package/backend/src/embedding/utils.ts +13 -0
- package/backend/src/embedding/worker.ts +89 -0
- package/backend/src/index.ts +147 -0
- package/backend/src/lib/auth-helpers.ts +27 -0
- package/backend/src/lib/auth.ts +35 -0
- package/backend/src/lib/config.ts +73 -0
- package/backend/src/lib/db.ts +7 -0
- package/backend/src/lib/embedding-queue.ts +12 -0
- package/backend/src/lib/logger.ts +18 -0
- package/backend/src/lib/markdown-to-doc.ts +45 -0
- package/backend/src/lib/minio.ts +46 -0
- package/backend/src/lib/redis.ts +19 -0
- package/backend/src/lib/yjs-provider.ts +182 -0
- package/backend/tests/integration/_harness.ts +754 -0
- package/backend/tests/integration/auth.test.ts +296 -0
- package/backend/tests/integration/routes.documents.test.ts +459 -0
- package/backend/tests/integration/routes.folders.test.ts +337 -0
- package/backend/tests/integration/routes.search.test.ts +322 -0
- package/backend/tests/integration/routes.share.test.ts +773 -0
- package/backend/tests/integration/routes.tags.test.ts +425 -0
- package/backend/tests/integration/routes.versions.test.ts +233 -0
- package/backend/tsconfig.json +18 -0
- package/docker-compose.yml +218 -0
- package/docs/API.md +328 -0
- package/docs/ARCHITECTURE.md +75 -0
- package/docs/DEPLOYMENT.md +113 -0
- package/docs/PRODUCTION_STATUS.md +61 -0
- package/docs/openapi.json +385 -0
- package/frontend/.svelte-kit.old/ambient.d.ts +230 -0
- package/frontend/.svelte-kit.old/env.d.ts +1 -0
- package/frontend/.svelte-kit.old/generated/client/app.js +46 -0
- package/frontend/.svelte-kit.old/generated/client/matchers.js +1 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/0.js +3 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/1.js +1 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/10.js +3 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/2.js +1 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/3.js +1 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/4.js +1 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/5.js +3 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/6.js +1 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/7.js +3 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/8.js +1 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/9.js +3 -0
- package/frontend/.svelte-kit.old/generated/root.js +3 -0
- package/frontend/.svelte-kit.old/generated/root.svelte +80 -0
- package/frontend/.svelte-kit.old/generated/server/internal.js +55 -0
- package/frontend/.svelte-kit.old/non-ambient.d.ts +59 -0
- package/frontend/.svelte-kit.old/tsconfig.json +59 -0
- package/frontend/.svelte-kit.old/types/route_meta_data.json +40 -0
- package/frontend/.svelte-kit.old/types/src/routes/$types.d.ts +21 -0
- package/frontend/.svelte-kit.old/types/src/routes/(app)/$types.d.ts +30 -0
- package/frontend/.svelte-kit.old/types/src/routes/(app)/docs/[id]/$types.d.ts +27 -0
- package/frontend/.svelte-kit.old/types/src/routes/(app)/docs/[id]/proxy+page.ts +25 -0
- package/frontend/.svelte-kit.old/types/src/routes/api/[...path]/$types.d.ts +10 -0
- package/frontend/.svelte-kit.old/types/src/routes/folders/[id]/$types.d.ts +27 -0
- package/frontend/.svelte-kit.old/types/src/routes/folders/[id]/proxy+page.ts +15 -0
- package/frontend/.svelte-kit.old/types/src/routes/login/$types.d.ts +17 -0
- package/frontend/.svelte-kit.old/types/src/routes/register/$types.d.ts +17 -0
- package/frontend/.svelte-kit.old/types/src/routes/s/[token]/$types.d.ts +20 -0
- package/frontend/.svelte-kit.old/types/src/routes/s/[token]/proxy+page.ts +6 -0
- package/frontend/.svelte-kit.old/types/src/routes/search/$types.d.ts +19 -0
- package/frontend/.svelte-kit.old/types/src/routes/search/proxy+page.ts +26 -0
- package/frontend/.svelte-kit.old/types/src/routes/settings/$types.d.ts +17 -0
- package/frontend/Dockerfile +44 -0
- package/frontend/biome.json +40 -0
- package/frontend/components.json +18 -0
- package/frontend/messages/en.json +434 -0
- package/frontend/package.json +70 -0
- package/frontend/project.inlang/settings.json +12 -0
- package/frontend/src/app.css +6 -0
- package/frontend/src/app.d.ts +13 -0
- package/frontend/src/app.html +30 -0
- package/frontend/src/hooks.server.ts +10 -0
- package/frontend/src/hooks.ts +10 -0
- package/frontend/src/lib/api/attachments.ts +45 -0
- package/frontend/src/lib/api/client.test.ts +15 -0
- package/frontend/src/lib/api/client.ts +57 -0
- package/frontend/src/lib/api/documents.ts +83 -0
- package/frontend/src/lib/api/folders.ts +180 -0
- package/frontend/src/lib/api/search.test.ts +52 -0
- package/frontend/src/lib/api/search.ts +128 -0
- package/frontend/src/lib/api/settings.ts +95 -0
- package/frontend/src/lib/api/share.ts +71 -0
- package/frontend/src/lib/api/tags.test.ts +91 -0
- package/frontend/src/lib/api/tags.ts +87 -0
- package/frontend/src/lib/auth-client.ts +10 -0
- package/frontend/src/lib/collaboration.ts +63 -0
- package/frontend/src/lib/components/AttachmentUpload.svelte +110 -0
- package/frontend/src/lib/components/DatePicker.svelte +322 -0
- package/frontend/src/lib/components/DocumentCard.svelte +166 -0
- package/frontend/src/lib/components/EmptyState.svelte +49 -0
- package/frontend/src/lib/components/FolderCard.svelte +93 -0
- package/frontend/src/lib/components/ScrollToTop.svelte +72 -0
- package/frontend/src/lib/components/SearchBar.svelte +47 -0
- package/frontend/src/lib/components/SearchResult.svelte +115 -0
- package/frontend/src/lib/components/SettingsDialog.svelte +271 -0
- package/frontend/src/lib/components/ShareDialog.svelte +158 -0
- package/frontend/src/lib/components/ShareLink.svelte +98 -0
- package/frontend/src/lib/components/TagCreateDialog.svelte +284 -0
- package/frontend/src/lib/components/VersionDiff.svelte +55 -0
- package/frontend/src/lib/components/VersionHistory.svelte +96 -0
- package/frontend/src/lib/components/editor/DocumentTitle.svelte +87 -0
- package/frontend/src/lib/components/editor/EditorToolbar.svelte +1367 -0
- package/frontend/src/lib/components/editor/HiAiEditor.svelte +531 -0
- package/frontend/src/lib/components/editor/LinkDialog.svelte +134 -0
- package/frontend/src/lib/components/editor/MarkdownToggle.svelte +88 -0
- package/frontend/src/lib/components/editor/editorExtensions.ts +53 -0
- package/frontend/src/lib/components/editor/markdown.ts +38 -0
- package/frontend/src/lib/components/sidebar/FolderTree.svelte +731 -0
- package/frontend/src/lib/components/sidebar/RecentDocs.svelte +311 -0
- package/frontend/src/lib/components/sidebar/Sidebar.svelte +156 -0
- package/frontend/src/lib/components/sidebar/TagList.svelte +200 -0
- package/frontend/src/lib/components/ui/confirm-dialog/ConfirmDialog.svelte +76 -0
- package/frontend/src/lib/components/ui/confirm-dialog/index.ts +1 -0
- package/frontend/src/lib/stores/tag-store.svelte.ts +56 -0
- package/frontend/src/lib/stores/theme.svelte.ts +97 -0
- package/frontend/src/lib/svelte.d.ts +6 -0
- package/frontend/src/lib/types.ts +44 -0
- package/frontend/src/lib/utils/clipboard.ts +17 -0
- package/frontend/src/lib/utils/strip-markdown.ts +59 -0
- package/frontend/src/lib/utils.ts +33 -0
- package/frontend/src/routes/(app)/+layout.svelte +17 -0
- package/frontend/src/routes/(app)/+page.server.ts +10 -0
- package/frontend/src/routes/(app)/+page.svelte +303 -0
- package/frontend/src/routes/(app)/docs/[id]/+page.server.ts +10 -0
- package/frontend/src/routes/(app)/docs/[id]/+page.svelte +1108 -0
- package/frontend/src/routes/(app)/docs/[id]/+page.ts +24 -0
- package/frontend/src/routes/(app)/search/+page.svelte +593 -0
- package/frontend/src/routes/(app)/search/+page.ts +25 -0
- package/frontend/src/routes/+error.svelte +12 -0
- package/frontend/src/routes/+layout.svelte +18 -0
- package/frontend/src/routes/+layout.ts +2 -0
- package/frontend/src/routes/api/[...path]/+server.ts +111 -0
- package/frontend/src/routes/folders/[id]/+page.server.ts +10 -0
- package/frontend/src/routes/folders/[id]/+page.svelte +319 -0
- package/frontend/src/routes/folders/[id]/+page.ts +14 -0
- package/frontend/src/routes/login/+page.svelte +90 -0
- package/frontend/src/routes/register/+page.svelte +97 -0
- package/frontend/src/routes/s/[token]/+page.svelte +496 -0
- package/frontend/src/routes/s/[token]/+page.ts +5 -0
- package/frontend/src/routes/settings/+page.svelte +175 -0
- package/frontend/static/favicon.png +0 -0
- package/frontend/static/logo.png +0 -0
- package/frontend/svelte.config.js +15 -0
- package/frontend/tsconfig.json +15 -0
- package/frontend/vite.config.ts +25 -0
- package/init.sql +9 -0
- package/logo.png +0 -0
- package/package.json +39 -0
- package/package.public.json +39 -0
- package/packages/db/drizzle.config.ts +10 -0
- package/packages/db/package.json +30 -0
- package/packages/db/src/client.ts +9 -0
- package/packages/db/src/index.ts +2 -0
- package/packages/db/src/migrations/0000_nice_bedlam.sql +165 -0
- package/packages/db/src/migrations/0001_w2_3_test.sql +5 -0
- package/packages/db/src/migrations/0002_rename_content_json.sql +2 -0
- package/packages/db/src/migrations/meta/0000_snapshot.json +1331 -0
- package/packages/db/src/migrations/meta/0001_snapshot.json +1399 -0
- package/packages/db/src/migrations/meta/0002_snapshot.json +1399 -0
- package/packages/db/src/migrations/meta/_journal.json +27 -0
- package/packages/db/src/schema.ts +378 -0
- package/packages/db/tsconfig.json +17 -0
- package/scripts/export-openapi.ts +37 -0
- package/scripts/health-check.sh +75 -0
- package/scripts/migrate.sh +135 -0
- package/scripts/prework_backup.sh +25 -0
- package/scripts/release.sh +83 -0
- package/tsconfig.json +25 -0
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP-level tests for tag routes.
|
|
3
|
+
* Tests:
|
|
4
|
+
* GET /api/tags
|
|
5
|
+
* POST /api/tags
|
|
6
|
+
* PATCH /api/tags/:id
|
|
7
|
+
* DELETE /api/tags/:id
|
|
8
|
+
* POST /api/documents/:id/tags
|
|
9
|
+
* DELETE /api/documents/:id/tags/:tagId
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test";
|
|
13
|
+
import {
|
|
14
|
+
OWNER_ID,
|
|
15
|
+
getState,
|
|
16
|
+
noAuthHeaders,
|
|
17
|
+
ownerHeaders,
|
|
18
|
+
request,
|
|
19
|
+
resetState,
|
|
20
|
+
setupHarness,
|
|
21
|
+
} from "./_harness";
|
|
22
|
+
|
|
23
|
+
let app: any;
|
|
24
|
+
|
|
25
|
+
beforeAll(async () => {
|
|
26
|
+
const built = await setupHarness();
|
|
27
|
+
app = built.app;
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
resetState();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
resetState();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
function authedGet(path: string) {
|
|
39
|
+
return request(app, path, { method: "GET", headers: ownerHeaders() });
|
|
40
|
+
}
|
|
41
|
+
function authedPost(path: string, body: any) {
|
|
42
|
+
return request(app, path, {
|
|
43
|
+
method: "POST",
|
|
44
|
+
headers: ownerHeaders(),
|
|
45
|
+
body: JSON.stringify(body),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
function authedPatch(path: string, body: any) {
|
|
49
|
+
return request(app, path, {
|
|
50
|
+
method: "PATCH",
|
|
51
|
+
headers: ownerHeaders(),
|
|
52
|
+
body: JSON.stringify(body),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
function authedDelete(path: string) {
|
|
56
|
+
return request(app, path, { method: "DELETE", headers: ownerHeaders() });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe("GET /api/tags", () => {
|
|
60
|
+
it("returns 401 without auth", async () => {
|
|
61
|
+
const res = await request(app, "/api/tags", {
|
|
62
|
+
method: "GET",
|
|
63
|
+
headers: noAuthHeaders(),
|
|
64
|
+
});
|
|
65
|
+
expect(res.status).toBe(401);
|
|
66
|
+
expect(res.body).toEqual({ error: "Unauthorized" });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("returns 200 with empty array when no tags exist", async () => {
|
|
70
|
+
const res = await authedGet("/api/tags");
|
|
71
|
+
expect(res.status).toBe(200);
|
|
72
|
+
expect(res.body).toEqual([]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("returns tags owned by the current user with document counts", async () => {
|
|
76
|
+
const state = getState();
|
|
77
|
+
state.tags.set("tag-1", {
|
|
78
|
+
id: "tag-1",
|
|
79
|
+
ownerId: OWNER_ID,
|
|
80
|
+
name: "alpha",
|
|
81
|
+
color: "#ff0000",
|
|
82
|
+
createdAt: new Date(),
|
|
83
|
+
});
|
|
84
|
+
state.tags.set("tag-2", {
|
|
85
|
+
id: "tag-2",
|
|
86
|
+
ownerId: OWNER_ID,
|
|
87
|
+
name: "beta",
|
|
88
|
+
color: null,
|
|
89
|
+
createdAt: new Date(),
|
|
90
|
+
});
|
|
91
|
+
state.tags.set("tag-other", {
|
|
92
|
+
id: "tag-other",
|
|
93
|
+
ownerId: "00000000-0000-4000-8000-000000000999",
|
|
94
|
+
name: "should-not-appear",
|
|
95
|
+
color: null,
|
|
96
|
+
createdAt: new Date(),
|
|
97
|
+
});
|
|
98
|
+
state.documentTags.push(
|
|
99
|
+
{ documentId: "doc-1", tagId: "tag-1" },
|
|
100
|
+
{ documentId: "doc-2", tagId: "tag-1" },
|
|
101
|
+
{ documentId: "doc-3", tagId: "tag-2" },
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
const res = await authedGet("/api/tags");
|
|
105
|
+
expect(res.status).toBe(200);
|
|
106
|
+
const items = res.body as Array<{
|
|
107
|
+
id: string;
|
|
108
|
+
name: string;
|
|
109
|
+
documentCount: number;
|
|
110
|
+
}>;
|
|
111
|
+
const tag1 = items.find((t) => t.id === "tag-1");
|
|
112
|
+
const tag2 = items.find((t) => t.id === "tag-2");
|
|
113
|
+
expect(tag1).toBeTruthy();
|
|
114
|
+
expect(tag2).toBeTruthy();
|
|
115
|
+
expect(tag1?.documentCount).toBe(2);
|
|
116
|
+
expect(tag2?.documentCount).toBe(1);
|
|
117
|
+
expect(items.find((t) => t.id === "tag-other")).toBeUndefined();
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("POST /api/tags", () => {
|
|
122
|
+
it("returns 403 from CSRF middleware without auth and without CSRF token", async () => {
|
|
123
|
+
const res = await request(app, "/api/tags", {
|
|
124
|
+
method: "POST",
|
|
125
|
+
headers: noAuthHeaders(),
|
|
126
|
+
body: JSON.stringify({ name: "x" }),
|
|
127
|
+
});
|
|
128
|
+
expect(res.status).toBe(403);
|
|
129
|
+
expect((res.body as any).error).toMatch(/CSRF/i);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("returns 400 for invalid input (missing name)", async () => {
|
|
133
|
+
const res = await authedPost("/api/tags", {});
|
|
134
|
+
expect(res.status).toBe(400);
|
|
135
|
+
expect((res.body as any).error).toBe("Invalid input");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("creates a tag and returns 201", async () => {
|
|
139
|
+
const res = await authedPost("/api/tags", {
|
|
140
|
+
name: "important",
|
|
141
|
+
color: "#00ff00",
|
|
142
|
+
});
|
|
143
|
+
expect(res.status).toBe(201);
|
|
144
|
+
const body = res.body as {
|
|
145
|
+
id: string;
|
|
146
|
+
name: string;
|
|
147
|
+
color: string;
|
|
148
|
+
ownerId: string;
|
|
149
|
+
};
|
|
150
|
+
expect(body.name).toBe("important");
|
|
151
|
+
expect(body.color).toBe("#00ff00");
|
|
152
|
+
expect(body.ownerId).toBe(OWNER_ID);
|
|
153
|
+
expect(body.id).toBeTruthy();
|
|
154
|
+
|
|
155
|
+
const stored = (getState().tags.get(body.id) as any) ?? null;
|
|
156
|
+
expect(stored).not.toBeNull();
|
|
157
|
+
expect(stored.name).toBe("important");
|
|
158
|
+
expect(stored.ownerId).toBe(OWNER_ID);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("allows color to be omitted", async () => {
|
|
162
|
+
const res = await authedPost("/api/tags", { name: "no-color" });
|
|
163
|
+
expect(res.status).toBe(201);
|
|
164
|
+
expect((res.body as any).name).toBe("no-color");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("returns 409 when a tag with the same name already exists", async () => {
|
|
168
|
+
getState().tags.set("existing", {
|
|
169
|
+
id: "existing",
|
|
170
|
+
ownerId: OWNER_ID,
|
|
171
|
+
name: "duplicate",
|
|
172
|
+
color: null,
|
|
173
|
+
createdAt: new Date(),
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const res = await authedPost("/api/tags", { name: "duplicate" });
|
|
177
|
+
expect(res.status).toBe(409);
|
|
178
|
+
expect((res.body as any).error).toMatch(/already exists/i);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe("PATCH /api/tags/:id", () => {
|
|
183
|
+
it("returns 400 for invalid body", async () => {
|
|
184
|
+
const res = await authedPatch(
|
|
185
|
+
"/api/tags/00000000-0000-4000-8000-000000000099",
|
|
186
|
+
{ name: "" },
|
|
187
|
+
);
|
|
188
|
+
expect(res.status).toBe(400);
|
|
189
|
+
expect((res.body as any).error).toBe("Invalid input");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("returns 404 when the tag does not exist", async () => {
|
|
193
|
+
const res = await authedPatch(
|
|
194
|
+
"/api/tags/00000000-0000-4000-8000-000000000099",
|
|
195
|
+
{ name: "renamed" },
|
|
196
|
+
);
|
|
197
|
+
expect(res.status).toBe(404);
|
|
198
|
+
expect((res.body as any).error).toBe("Tag not found");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("renames a tag", async () => {
|
|
202
|
+
const id = "11111111-1111-4111-8111-111111111111";
|
|
203
|
+
getState().tags.set(id, {
|
|
204
|
+
id,
|
|
205
|
+
ownerId: OWNER_ID,
|
|
206
|
+
name: "old",
|
|
207
|
+
color: "#000000",
|
|
208
|
+
createdAt: new Date(),
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
const res = await authedPatch(`/api/tags/${id}`, { name: "new" });
|
|
212
|
+
expect(res.status).toBe(200);
|
|
213
|
+
expect((res.body as any).name).toBe("new");
|
|
214
|
+
expect((getState().tags.get(id) as any).name).toBe("new");
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("updates color", async () => {
|
|
218
|
+
const id = "22222222-2222-4222-8222-222222222222";
|
|
219
|
+
getState().tags.set(id, {
|
|
220
|
+
id,
|
|
221
|
+
ownerId: OWNER_ID,
|
|
222
|
+
name: "colored",
|
|
223
|
+
color: "#000000",
|
|
224
|
+
createdAt: new Date(),
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
const res = await authedPatch(`/api/tags/${id}`, { color: "#ffffff" });
|
|
228
|
+
expect(res.status).toBe(200);
|
|
229
|
+
expect((res.body as any).color).toBe("#ffffff");
|
|
230
|
+
expect((getState().tags.get(id) as any).color).toBe("#ffffff");
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("does not update tags owned by other users", async () => {
|
|
234
|
+
const id = "33333333-3333-4333-8333-333333333333";
|
|
235
|
+
getState().tags.set(id, {
|
|
236
|
+
id,
|
|
237
|
+
ownerId: "00000000-0000-4000-8000-000000000999",
|
|
238
|
+
name: "other-user",
|
|
239
|
+
color: null,
|
|
240
|
+
createdAt: new Date(),
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
const res = await authedPatch(`/api/tags/${id}`, { name: "hijack" });
|
|
244
|
+
expect(res.status).toBe(404);
|
|
245
|
+
expect((getState().tags.get(id) as any).name).toBe("other-user");
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
describe("DELETE /api/tags/:id", () => {
|
|
250
|
+
it("returns 200 and removes the tag", async () => {
|
|
251
|
+
const id = "44444444-4444-4444-8444-444444444444";
|
|
252
|
+
getState().tags.set(id, {
|
|
253
|
+
id,
|
|
254
|
+
ownerId: OWNER_ID,
|
|
255
|
+
name: "trash-me",
|
|
256
|
+
color: null,
|
|
257
|
+
createdAt: new Date(),
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
const res = await authedDelete(`/api/tags/${id}`);
|
|
261
|
+
expect(res.status).toBe(200);
|
|
262
|
+
expect(res.body).toEqual({ success: true });
|
|
263
|
+
expect(getState().tags.has(id)).toBe(false);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("does not delete tags owned by other users", async () => {
|
|
267
|
+
const id = "55555555-5555-4555-8555-555555555555";
|
|
268
|
+
getState().tags.set(id, {
|
|
269
|
+
id,
|
|
270
|
+
ownerId: "00000000-0000-4000-8000-000000000999",
|
|
271
|
+
name: "not-mine",
|
|
272
|
+
color: null,
|
|
273
|
+
createdAt: new Date(),
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
const res = await authedDelete(`/api/tags/${id}`);
|
|
277
|
+
// The handler does not 404 on foreign tags — it returns success without deleting.
|
|
278
|
+
expect(res.status).toBe(200);
|
|
279
|
+
expect(getState().tags.has(id)).toBe(true);
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
describe("POST /api/documents/:id/tags", () => {
|
|
284
|
+
const docId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
|
285
|
+
const tagId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
|
|
286
|
+
|
|
287
|
+
function seed() {
|
|
288
|
+
const state = getState();
|
|
289
|
+
state.documents.set(docId, {
|
|
290
|
+
id: docId,
|
|
291
|
+
ownerId: OWNER_ID,
|
|
292
|
+
folderId: null,
|
|
293
|
+
title: "Doc",
|
|
294
|
+
content: "",
|
|
295
|
+
createdAt: new Date(),
|
|
296
|
+
updatedAt: new Date(),
|
|
297
|
+
});
|
|
298
|
+
state.tags.set(tagId, {
|
|
299
|
+
id: tagId,
|
|
300
|
+
ownerId: OWNER_ID,
|
|
301
|
+
name: "label",
|
|
302
|
+
color: null,
|
|
303
|
+
createdAt: new Date(),
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
it("returns 400 when tagId is missing or invalid", async () => {
|
|
308
|
+
seed();
|
|
309
|
+
const res = await authedPost(`/api/documents/${docId}/tags`, {});
|
|
310
|
+
expect(res.status).toBe(400);
|
|
311
|
+
expect((res.body as any).error).toBe("Invalid input");
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("returns 404 when the document does not exist", async () => {
|
|
315
|
+
const res = await authedPost(
|
|
316
|
+
"/api/documents/00000000-0000-4000-8000-000000000099/tags",
|
|
317
|
+
{ tagId },
|
|
318
|
+
);
|
|
319
|
+
expect(res.status).toBe(404);
|
|
320
|
+
expect((res.body as any).error).toBe("Document not found");
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("adds a tag to a document and returns 201", async () => {
|
|
324
|
+
seed();
|
|
325
|
+
const res = await authedPost(`/api/documents/${docId}/tags`, { tagId });
|
|
326
|
+
expect(res.status).toBe(201);
|
|
327
|
+
expect(res.body).toEqual({ success: true });
|
|
328
|
+
// Note: this route uses `db.insert(documentTags).values(...)` without
|
|
329
|
+
// `.returning()`, so the mock harness does not persist the row into
|
|
330
|
+
// state.documentTags. The HTTP response is the contract under test.
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("does not add tags to documents owned by other users", async () => {
|
|
334
|
+
const state = getState();
|
|
335
|
+
state.documents.set(docId, {
|
|
336
|
+
id: docId,
|
|
337
|
+
ownerId: "00000000-0000-4000-8000-000000000999",
|
|
338
|
+
folderId: null,
|
|
339
|
+
title: "Other",
|
|
340
|
+
content: "",
|
|
341
|
+
createdAt: new Date(),
|
|
342
|
+
updatedAt: new Date(),
|
|
343
|
+
});
|
|
344
|
+
state.tags.set(tagId, {
|
|
345
|
+
id: tagId,
|
|
346
|
+
ownerId: OWNER_ID,
|
|
347
|
+
name: "label",
|
|
348
|
+
color: null,
|
|
349
|
+
createdAt: new Date(),
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
const res = await authedPost(`/api/documents/${docId}/tags`, { tagId });
|
|
353
|
+
expect(res.status).toBe(404);
|
|
354
|
+
expect(getState().documentTags.length).toBe(0);
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
describe("DELETE /api/documents/:id/tags/:tagId", () => {
|
|
359
|
+
const docId = "cccccccc-cccc-4ccc-8ccc-cccccccccccc";
|
|
360
|
+
const tagId = "dddddddd-dddd-4ddd-8ddd-dddddddddddd";
|
|
361
|
+
|
|
362
|
+
function seed() {
|
|
363
|
+
const state = getState();
|
|
364
|
+
state.documents.set(docId, {
|
|
365
|
+
id: docId,
|
|
366
|
+
ownerId: OWNER_ID,
|
|
367
|
+
folderId: null,
|
|
368
|
+
title: "Doc",
|
|
369
|
+
content: "",
|
|
370
|
+
createdAt: new Date(),
|
|
371
|
+
updatedAt: new Date(),
|
|
372
|
+
});
|
|
373
|
+
state.tags.set(tagId, {
|
|
374
|
+
id: tagId,
|
|
375
|
+
ownerId: OWNER_ID,
|
|
376
|
+
name: "label",
|
|
377
|
+
color: null,
|
|
378
|
+
createdAt: new Date(),
|
|
379
|
+
});
|
|
380
|
+
state.documentTags.push({ documentId: docId, tagId });
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
it("returns 404 when the document does not exist", async () => {
|
|
384
|
+
const res = await authedDelete(
|
|
385
|
+
`/api/documents/00000000-0000-4000-8000-000000000099/tags/${tagId}`,
|
|
386
|
+
);
|
|
387
|
+
expect(res.status).toBe(404);
|
|
388
|
+
expect((res.body as any).error).toBe("Document not found");
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it("removes a tag from a document", async () => {
|
|
392
|
+
seed();
|
|
393
|
+
const res = await authedDelete(`/api/documents/${docId}/tags/${tagId}`);
|
|
394
|
+
expect(res.status).toBe(200);
|
|
395
|
+
expect(res.body).toEqual({ success: true });
|
|
396
|
+
// Note: this route uses `db.delete(documentTags).where(...)` without
|
|
397
|
+
// `.returning()`, so the mock harness does not mutate state.documentTags.
|
|
398
|
+
// The HTTP response is the contract under test.
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it("does not remove tags from documents owned by other users", async () => {
|
|
402
|
+
const state = getState();
|
|
403
|
+
state.documents.set(docId, {
|
|
404
|
+
id: docId,
|
|
405
|
+
ownerId: "00000000-0000-4000-8000-000000000999",
|
|
406
|
+
folderId: null,
|
|
407
|
+
title: "Other",
|
|
408
|
+
content: "",
|
|
409
|
+
createdAt: new Date(),
|
|
410
|
+
updatedAt: new Date(),
|
|
411
|
+
});
|
|
412
|
+
state.tags.set(tagId, {
|
|
413
|
+
id: tagId,
|
|
414
|
+
ownerId: OWNER_ID,
|
|
415
|
+
name: "label",
|
|
416
|
+
color: null,
|
|
417
|
+
createdAt: new Date(),
|
|
418
|
+
});
|
|
419
|
+
state.documentTags.push({ documentId: docId, tagId });
|
|
420
|
+
|
|
421
|
+
const res = await authedDelete(`/api/documents/${docId}/tags/${tagId}`);
|
|
422
|
+
expect(res.status).toBe(404);
|
|
423
|
+
expect(getState().documentTags.length).toBe(1);
|
|
424
|
+
});
|
|
425
|
+
});
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP-level tests for version routes.
|
|
3
|
+
* Tests:
|
|
4
|
+
* GET /api/documents/:id/versions
|
|
5
|
+
* GET /api/documents/:id/versions/:vid
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test";
|
|
9
|
+
import {
|
|
10
|
+
OWNER_ID,
|
|
11
|
+
getState,
|
|
12
|
+
noAuthHeaders,
|
|
13
|
+
ownerHeaders,
|
|
14
|
+
request,
|
|
15
|
+
resetState,
|
|
16
|
+
setupHarness,
|
|
17
|
+
} from "./_harness";
|
|
18
|
+
|
|
19
|
+
let app: any;
|
|
20
|
+
|
|
21
|
+
beforeAll(async () => {
|
|
22
|
+
const built = await setupHarness();
|
|
23
|
+
app = built.app;
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
resetState();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
resetState();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
function authedGet(path: string) {
|
|
35
|
+
return request(app, path, { method: "GET", headers: ownerHeaders() });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const docId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
|
39
|
+
const otherDocId = "99999999-9999-4999-8999-999999999999";
|
|
40
|
+
|
|
41
|
+
function seedDocument(id: string = docId, owner: string = OWNER_ID) {
|
|
42
|
+
getState().documents.set(id, {
|
|
43
|
+
id,
|
|
44
|
+
ownerId: owner,
|
|
45
|
+
folderId: null,
|
|
46
|
+
title: "Doc",
|
|
47
|
+
content: "",
|
|
48
|
+
createdAt: new Date(),
|
|
49
|
+
updatedAt: new Date(),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function seedVersions() {
|
|
54
|
+
getState().versions.push(
|
|
55
|
+
{
|
|
56
|
+
id: "v-old",
|
|
57
|
+
documentId: docId,
|
|
58
|
+
content: "first draft",
|
|
59
|
+
contentJson: null,
|
|
60
|
+
createdBy: OWNER_ID,
|
|
61
|
+
createdAt: new Date("2024-01-01T00:00:00Z"),
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
id: "v-mid",
|
|
65
|
+
documentId: docId,
|
|
66
|
+
content: "second draft",
|
|
67
|
+
contentJson: null,
|
|
68
|
+
createdBy: OWNER_ID,
|
|
69
|
+
createdAt: new Date("2024-02-01T00:00:00Z"),
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: "v-new",
|
|
73
|
+
documentId: docId,
|
|
74
|
+
content: "final draft",
|
|
75
|
+
contentJson: null,
|
|
76
|
+
createdBy: OWNER_ID,
|
|
77
|
+
createdAt: new Date("2024-03-01T00:00:00Z"),
|
|
78
|
+
},
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
describe("GET /api/documents/:id/versions", () => {
|
|
83
|
+
it("returns 401 without auth", async () => {
|
|
84
|
+
const res = await request(app, `/api/documents/${docId}/versions`, {
|
|
85
|
+
method: "GET",
|
|
86
|
+
headers: noAuthHeaders(),
|
|
87
|
+
});
|
|
88
|
+
expect(res.status).toBe(401);
|
|
89
|
+
expect(res.body).toEqual({ error: "Unauthorized" });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("returns 404 when the document does not exist", async () => {
|
|
93
|
+
const res = await authedGet(
|
|
94
|
+
`/api/documents/00000000-0000-4000-8000-000000000099/versions`,
|
|
95
|
+
);
|
|
96
|
+
expect(res.status).toBe(404);
|
|
97
|
+
expect((res.body as any).error).toBe("Document not found");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("returns 404 for documents owned by other users", async () => {
|
|
101
|
+
seedDocument(otherDocId, "00000000-0000-4000-8000-000000000999");
|
|
102
|
+
const res = await authedGet(`/api/documents/${otherDocId}/versions`);
|
|
103
|
+
expect(res.status).toBe(404);
|
|
104
|
+
expect((res.body as any).error).toBe("Document not found");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("returns an empty array when the document has no versions", async () => {
|
|
108
|
+
seedDocument();
|
|
109
|
+
const res = await authedGet(`/api/documents/${docId}/versions`);
|
|
110
|
+
expect(res.status).toBe(200);
|
|
111
|
+
expect(res.body).toEqual([]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("returns all versions for the document ordered newest-first", async () => {
|
|
115
|
+
seedDocument();
|
|
116
|
+
seedVersions();
|
|
117
|
+
|
|
118
|
+
const res = await authedGet(`/api/documents/${docId}/versions`);
|
|
119
|
+
expect(res.status).toBe(200);
|
|
120
|
+
const items = res.body as Array<{ id: string; createdAt: string }>;
|
|
121
|
+
expect(items.length).toBe(3);
|
|
122
|
+
expect(items.map((v) => v.id)).toEqual(["v-new", "v-mid", "v-old"]);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("only returns versions belonging to the requested document", async () => {
|
|
126
|
+
seedDocument();
|
|
127
|
+
seedDocument("other-doc");
|
|
128
|
+
getState().versions.push(
|
|
129
|
+
{
|
|
130
|
+
id: "v-x",
|
|
131
|
+
documentId: "other-doc",
|
|
132
|
+
content: "unrelated",
|
|
133
|
+
contentJson: null,
|
|
134
|
+
createdBy: OWNER_ID,
|
|
135
|
+
createdAt: new Date("2024-04-01T00:00:00Z"),
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
id: "v-y",
|
|
139
|
+
documentId: docId,
|
|
140
|
+
content: "mine",
|
|
141
|
+
contentJson: null,
|
|
142
|
+
createdBy: OWNER_ID,
|
|
143
|
+
createdAt: new Date("2024-04-02T00:00:00Z"),
|
|
144
|
+
},
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
const res = await authedGet(`/api/documents/${docId}/versions`);
|
|
148
|
+
expect(res.status).toBe(200);
|
|
149
|
+
const items = res.body as Array<{ id: string }>;
|
|
150
|
+
const ids = items.map((v) => v.id);
|
|
151
|
+
expect(ids).toContain("v-y");
|
|
152
|
+
expect(ids).not.toContain("v-x");
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe("GET /api/documents/:id/versions/:vid", () => {
|
|
157
|
+
it("returns 401 without auth", async () => {
|
|
158
|
+
const res = await request(app, `/api/documents/${docId}/versions/v-new`, {
|
|
159
|
+
method: "GET",
|
|
160
|
+
headers: noAuthHeaders(),
|
|
161
|
+
});
|
|
162
|
+
expect(res.status).toBe(401);
|
|
163
|
+
expect(res.body).toEqual({ error: "Unauthorized" });
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("returns 404 when the document does not exist", async () => {
|
|
167
|
+
const res = await authedGet(
|
|
168
|
+
`/api/documents/00000000-0000-4000-8000-000000000099/versions/v-new`,
|
|
169
|
+
);
|
|
170
|
+
expect(res.status).toBe(404);
|
|
171
|
+
expect((res.body as any).error).toBe("Document not found");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("returns 404 when the version does not exist", async () => {
|
|
175
|
+
seedDocument();
|
|
176
|
+
const res = await authedGet(`/api/documents/${docId}/versions/v-missing`);
|
|
177
|
+
expect(res.status).toBe(404);
|
|
178
|
+
expect((res.body as any).error).toBe("Version not found");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("returns the requested version", async () => {
|
|
182
|
+
seedDocument();
|
|
183
|
+
seedVersions();
|
|
184
|
+
|
|
185
|
+
const res = await authedGet(`/api/documents/${docId}/versions/v-mid`);
|
|
186
|
+
expect(res.status).toBe(200);
|
|
187
|
+
const body = res.body as {
|
|
188
|
+
id: string;
|
|
189
|
+
documentId: string;
|
|
190
|
+
content: string;
|
|
191
|
+
};
|
|
192
|
+
expect(body.id).toBe("v-mid");
|
|
193
|
+
expect(body.documentId).toBe(docId);
|
|
194
|
+
expect(body.content).toBe("second draft");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("does not return a version that belongs to a different document", async () => {
|
|
198
|
+
seedDocument();
|
|
199
|
+
seedDocument("other-doc");
|
|
200
|
+
getState().versions.push({
|
|
201
|
+
id: "v-other-doc",
|
|
202
|
+
documentId: "other-doc",
|
|
203
|
+
content: "no-peeking",
|
|
204
|
+
contentJson: null,
|
|
205
|
+
createdBy: OWNER_ID,
|
|
206
|
+
createdAt: new Date("2024-05-01T00:00:00Z"),
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const res = await authedGet(
|
|
210
|
+
`/api/documents/${docId}/versions/v-other-doc`,
|
|
211
|
+
);
|
|
212
|
+
expect(res.status).toBe(404);
|
|
213
|
+
expect((res.body as any).error).toBe("Version not found");
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("does not return versions for documents owned by other users", async () => {
|
|
217
|
+
seedDocument(otherDocId, "00000000-0000-4000-8000-000000000999");
|
|
218
|
+
getState().versions.push({
|
|
219
|
+
id: "v-foreign",
|
|
220
|
+
documentId: otherDocId,
|
|
221
|
+
content: "secret",
|
|
222
|
+
contentJson: null,
|
|
223
|
+
createdBy: "00000000-0000-4000-8000-000000000999",
|
|
224
|
+
createdAt: new Date("2024-06-01T00:00:00Z"),
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
const res = await authedGet(
|
|
228
|
+
`/api/documents/${otherDocId}/versions/v-foreign`,
|
|
229
|
+
);
|
|
230
|
+
expect(res.status).toBe(404);
|
|
231
|
+
expect((res.body as any).error).toBe("Document not found");
|
|
232
|
+
});
|
|
233
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ESNext",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"noUncheckedIndexedAccess": true,
|
|
8
|
+
"esModuleInterop": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"outDir": "./dist",
|
|
11
|
+
"rootDir": "./src",
|
|
12
|
+
"declaration": true,
|
|
13
|
+
"sourceMap": true,
|
|
14
|
+
"types": ["@types/bun"]
|
|
15
|
+
},
|
|
16
|
+
"include": ["src"],
|
|
17
|
+
"exclude": ["node_modules", "dist"]
|
|
18
|
+
}
|