@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.
Files changed (216) hide show
  1. package/.all-contributorsrc +18 -0
  2. package/.claude/settings.local.json +61 -0
  3. package/.dockerignore +113 -0
  4. package/.env.example +68 -0
  5. package/.github/FUNDING.yml +5 -0
  6. package/.github/ISSUE_TEMPLATE/bug_report.md +74 -0
  7. package/.github/ISSUE_TEMPLATE/feature_request.md +78 -0
  8. package/.github/dependabot.yml +136 -0
  9. package/.github/pull_request_template.md +96 -0
  10. package/.github/workflows/ci.yml +283 -0
  11. package/AGENTS.md +237 -0
  12. package/CODE_OF_CONDUCT.md +134 -0
  13. package/CONTRIBUTING.md +77 -0
  14. package/Caddyfile +50 -0
  15. package/Dockerfile.backend +60 -0
  16. package/LICENSE +21 -0
  17. package/README.md +284 -0
  18. package/RELEASE_CHECKLIST.md +34 -0
  19. package/SECURITY.md +60 -0
  20. package/backend/package.json +43 -0
  21. package/backend/src/__tests__/auth-helpers.test.ts +51 -0
  22. package/backend/src/__tests__/chunker.test.ts +65 -0
  23. package/backend/src/__tests__/config.test.ts +91 -0
  24. package/backend/src/__tests__/csrf.test.ts +91 -0
  25. package/backend/src/__tests__/embedding.test.ts +48 -0
  26. package/backend/src/__tests__/rate-limit.test.ts +46 -0
  27. package/backend/src/__tests__/routes.test.ts +38 -0
  28. package/backend/src/__tests__/schema.test.ts +31 -0
  29. package/backend/src/__tests__/validation.test.ts +556 -0
  30. package/backend/src/api/middleware/auth.ts +56 -0
  31. package/backend/src/api/middleware/csrf.ts +91 -0
  32. package/backend/src/api/middleware/rate-limit.ts +77 -0
  33. package/backend/src/api/middleware/webhook-verify.ts +22 -0
  34. package/backend/src/api/routes/attachments.ts +280 -0
  35. package/backend/src/api/routes/auth.ts +52 -0
  36. package/backend/src/api/routes/collaboration.ts +121 -0
  37. package/backend/src/api/routes/documents.ts +664 -0
  38. package/backend/src/api/routes/folders.ts +226 -0
  39. package/backend/src/api/routes/search.ts +354 -0
  40. package/backend/src/api/routes/share.ts +512 -0
  41. package/backend/src/api/routes/tags.ts +247 -0
  42. package/backend/src/api/routes/versions.ts +99 -0
  43. package/backend/src/api/routes/webhooks.ts +43 -0
  44. package/backend/src/embedding/chunker.ts +74 -0
  45. package/backend/src/embedding/index.ts +117 -0
  46. package/backend/src/embedding/providers/ollama.ts +63 -0
  47. package/backend/src/embedding/providers/openrouter.ts +71 -0
  48. package/backend/src/embedding/utils.ts +13 -0
  49. package/backend/src/embedding/worker.ts +89 -0
  50. package/backend/src/index.ts +147 -0
  51. package/backend/src/lib/auth-helpers.ts +27 -0
  52. package/backend/src/lib/auth.ts +35 -0
  53. package/backend/src/lib/config.ts +73 -0
  54. package/backend/src/lib/db.ts +7 -0
  55. package/backend/src/lib/embedding-queue.ts +12 -0
  56. package/backend/src/lib/logger.ts +18 -0
  57. package/backend/src/lib/markdown-to-doc.ts +45 -0
  58. package/backend/src/lib/minio.ts +46 -0
  59. package/backend/src/lib/redis.ts +19 -0
  60. package/backend/src/lib/yjs-provider.ts +182 -0
  61. package/backend/tests/integration/_harness.ts +754 -0
  62. package/backend/tests/integration/auth.test.ts +296 -0
  63. package/backend/tests/integration/routes.documents.test.ts +459 -0
  64. package/backend/tests/integration/routes.folders.test.ts +337 -0
  65. package/backend/tests/integration/routes.search.test.ts +322 -0
  66. package/backend/tests/integration/routes.share.test.ts +773 -0
  67. package/backend/tests/integration/routes.tags.test.ts +425 -0
  68. package/backend/tests/integration/routes.versions.test.ts +233 -0
  69. package/backend/tsconfig.json +18 -0
  70. package/docker-compose.yml +218 -0
  71. package/docs/API.md +328 -0
  72. package/docs/ARCHITECTURE.md +75 -0
  73. package/docs/DEPLOYMENT.md +113 -0
  74. package/docs/PRODUCTION_STATUS.md +61 -0
  75. package/docs/openapi.json +385 -0
  76. package/frontend/.svelte-kit.old/ambient.d.ts +230 -0
  77. package/frontend/.svelte-kit.old/env.d.ts +1 -0
  78. package/frontend/.svelte-kit.old/generated/client/app.js +46 -0
  79. package/frontend/.svelte-kit.old/generated/client/matchers.js +1 -0
  80. package/frontend/.svelte-kit.old/generated/client/nodes/0.js +3 -0
  81. package/frontend/.svelte-kit.old/generated/client/nodes/1.js +1 -0
  82. package/frontend/.svelte-kit.old/generated/client/nodes/10.js +3 -0
  83. package/frontend/.svelte-kit.old/generated/client/nodes/2.js +1 -0
  84. package/frontend/.svelte-kit.old/generated/client/nodes/3.js +1 -0
  85. package/frontend/.svelte-kit.old/generated/client/nodes/4.js +1 -0
  86. package/frontend/.svelte-kit.old/generated/client/nodes/5.js +3 -0
  87. package/frontend/.svelte-kit.old/generated/client/nodes/6.js +1 -0
  88. package/frontend/.svelte-kit.old/generated/client/nodes/7.js +3 -0
  89. package/frontend/.svelte-kit.old/generated/client/nodes/8.js +1 -0
  90. package/frontend/.svelte-kit.old/generated/client/nodes/9.js +3 -0
  91. package/frontend/.svelte-kit.old/generated/root.js +3 -0
  92. package/frontend/.svelte-kit.old/generated/root.svelte +80 -0
  93. package/frontend/.svelte-kit.old/generated/server/internal.js +55 -0
  94. package/frontend/.svelte-kit.old/non-ambient.d.ts +59 -0
  95. package/frontend/.svelte-kit.old/tsconfig.json +59 -0
  96. package/frontend/.svelte-kit.old/types/route_meta_data.json +40 -0
  97. package/frontend/.svelte-kit.old/types/src/routes/$types.d.ts +21 -0
  98. package/frontend/.svelte-kit.old/types/src/routes/(app)/$types.d.ts +30 -0
  99. package/frontend/.svelte-kit.old/types/src/routes/(app)/docs/[id]/$types.d.ts +27 -0
  100. package/frontend/.svelte-kit.old/types/src/routes/(app)/docs/[id]/proxy+page.ts +25 -0
  101. package/frontend/.svelte-kit.old/types/src/routes/api/[...path]/$types.d.ts +10 -0
  102. package/frontend/.svelte-kit.old/types/src/routes/folders/[id]/$types.d.ts +27 -0
  103. package/frontend/.svelte-kit.old/types/src/routes/folders/[id]/proxy+page.ts +15 -0
  104. package/frontend/.svelte-kit.old/types/src/routes/login/$types.d.ts +17 -0
  105. package/frontend/.svelte-kit.old/types/src/routes/register/$types.d.ts +17 -0
  106. package/frontend/.svelte-kit.old/types/src/routes/s/[token]/$types.d.ts +20 -0
  107. package/frontend/.svelte-kit.old/types/src/routes/s/[token]/proxy+page.ts +6 -0
  108. package/frontend/.svelte-kit.old/types/src/routes/search/$types.d.ts +19 -0
  109. package/frontend/.svelte-kit.old/types/src/routes/search/proxy+page.ts +26 -0
  110. package/frontend/.svelte-kit.old/types/src/routes/settings/$types.d.ts +17 -0
  111. package/frontend/Dockerfile +44 -0
  112. package/frontend/biome.json +40 -0
  113. package/frontend/components.json +18 -0
  114. package/frontend/messages/en.json +434 -0
  115. package/frontend/package.json +70 -0
  116. package/frontend/project.inlang/settings.json +12 -0
  117. package/frontend/src/app.css +6 -0
  118. package/frontend/src/app.d.ts +13 -0
  119. package/frontend/src/app.html +30 -0
  120. package/frontend/src/hooks.server.ts +10 -0
  121. package/frontend/src/hooks.ts +10 -0
  122. package/frontend/src/lib/api/attachments.ts +45 -0
  123. package/frontend/src/lib/api/client.test.ts +15 -0
  124. package/frontend/src/lib/api/client.ts +57 -0
  125. package/frontend/src/lib/api/documents.ts +83 -0
  126. package/frontend/src/lib/api/folders.ts +180 -0
  127. package/frontend/src/lib/api/search.test.ts +52 -0
  128. package/frontend/src/lib/api/search.ts +128 -0
  129. package/frontend/src/lib/api/settings.ts +95 -0
  130. package/frontend/src/lib/api/share.ts +71 -0
  131. package/frontend/src/lib/api/tags.test.ts +91 -0
  132. package/frontend/src/lib/api/tags.ts +87 -0
  133. package/frontend/src/lib/auth-client.ts +10 -0
  134. package/frontend/src/lib/collaboration.ts +63 -0
  135. package/frontend/src/lib/components/AttachmentUpload.svelte +110 -0
  136. package/frontend/src/lib/components/DatePicker.svelte +322 -0
  137. package/frontend/src/lib/components/DocumentCard.svelte +166 -0
  138. package/frontend/src/lib/components/EmptyState.svelte +49 -0
  139. package/frontend/src/lib/components/FolderCard.svelte +93 -0
  140. package/frontend/src/lib/components/ScrollToTop.svelte +72 -0
  141. package/frontend/src/lib/components/SearchBar.svelte +47 -0
  142. package/frontend/src/lib/components/SearchResult.svelte +115 -0
  143. package/frontend/src/lib/components/SettingsDialog.svelte +271 -0
  144. package/frontend/src/lib/components/ShareDialog.svelte +158 -0
  145. package/frontend/src/lib/components/ShareLink.svelte +98 -0
  146. package/frontend/src/lib/components/TagCreateDialog.svelte +284 -0
  147. package/frontend/src/lib/components/VersionDiff.svelte +55 -0
  148. package/frontend/src/lib/components/VersionHistory.svelte +96 -0
  149. package/frontend/src/lib/components/editor/DocumentTitle.svelte +87 -0
  150. package/frontend/src/lib/components/editor/EditorToolbar.svelte +1367 -0
  151. package/frontend/src/lib/components/editor/HiAiEditor.svelte +531 -0
  152. package/frontend/src/lib/components/editor/LinkDialog.svelte +134 -0
  153. package/frontend/src/lib/components/editor/MarkdownToggle.svelte +88 -0
  154. package/frontend/src/lib/components/editor/editorExtensions.ts +53 -0
  155. package/frontend/src/lib/components/editor/markdown.ts +38 -0
  156. package/frontend/src/lib/components/sidebar/FolderTree.svelte +731 -0
  157. package/frontend/src/lib/components/sidebar/RecentDocs.svelte +311 -0
  158. package/frontend/src/lib/components/sidebar/Sidebar.svelte +156 -0
  159. package/frontend/src/lib/components/sidebar/TagList.svelte +200 -0
  160. package/frontend/src/lib/components/ui/confirm-dialog/ConfirmDialog.svelte +76 -0
  161. package/frontend/src/lib/components/ui/confirm-dialog/index.ts +1 -0
  162. package/frontend/src/lib/stores/tag-store.svelte.ts +56 -0
  163. package/frontend/src/lib/stores/theme.svelte.ts +97 -0
  164. package/frontend/src/lib/svelte.d.ts +6 -0
  165. package/frontend/src/lib/types.ts +44 -0
  166. package/frontend/src/lib/utils/clipboard.ts +17 -0
  167. package/frontend/src/lib/utils/strip-markdown.ts +59 -0
  168. package/frontend/src/lib/utils.ts +33 -0
  169. package/frontend/src/routes/(app)/+layout.svelte +17 -0
  170. package/frontend/src/routes/(app)/+page.server.ts +10 -0
  171. package/frontend/src/routes/(app)/+page.svelte +303 -0
  172. package/frontend/src/routes/(app)/docs/[id]/+page.server.ts +10 -0
  173. package/frontend/src/routes/(app)/docs/[id]/+page.svelte +1108 -0
  174. package/frontend/src/routes/(app)/docs/[id]/+page.ts +24 -0
  175. package/frontend/src/routes/(app)/search/+page.svelte +593 -0
  176. package/frontend/src/routes/(app)/search/+page.ts +25 -0
  177. package/frontend/src/routes/+error.svelte +12 -0
  178. package/frontend/src/routes/+layout.svelte +18 -0
  179. package/frontend/src/routes/+layout.ts +2 -0
  180. package/frontend/src/routes/api/[...path]/+server.ts +111 -0
  181. package/frontend/src/routes/folders/[id]/+page.server.ts +10 -0
  182. package/frontend/src/routes/folders/[id]/+page.svelte +319 -0
  183. package/frontend/src/routes/folders/[id]/+page.ts +14 -0
  184. package/frontend/src/routes/login/+page.svelte +90 -0
  185. package/frontend/src/routes/register/+page.svelte +97 -0
  186. package/frontend/src/routes/s/[token]/+page.svelte +496 -0
  187. package/frontend/src/routes/s/[token]/+page.ts +5 -0
  188. package/frontend/src/routes/settings/+page.svelte +175 -0
  189. package/frontend/static/favicon.png +0 -0
  190. package/frontend/static/logo.png +0 -0
  191. package/frontend/svelte.config.js +15 -0
  192. package/frontend/tsconfig.json +15 -0
  193. package/frontend/vite.config.ts +25 -0
  194. package/init.sql +9 -0
  195. package/logo.png +0 -0
  196. package/package.json +39 -0
  197. package/package.public.json +39 -0
  198. package/packages/db/drizzle.config.ts +10 -0
  199. package/packages/db/package.json +30 -0
  200. package/packages/db/src/client.ts +9 -0
  201. package/packages/db/src/index.ts +2 -0
  202. package/packages/db/src/migrations/0000_nice_bedlam.sql +165 -0
  203. package/packages/db/src/migrations/0001_w2_3_test.sql +5 -0
  204. package/packages/db/src/migrations/0002_rename_content_json.sql +2 -0
  205. package/packages/db/src/migrations/meta/0000_snapshot.json +1331 -0
  206. package/packages/db/src/migrations/meta/0001_snapshot.json +1399 -0
  207. package/packages/db/src/migrations/meta/0002_snapshot.json +1399 -0
  208. package/packages/db/src/migrations/meta/_journal.json +27 -0
  209. package/packages/db/src/schema.ts +378 -0
  210. package/packages/db/tsconfig.json +17 -0
  211. package/scripts/export-openapi.ts +37 -0
  212. package/scripts/health-check.sh +75 -0
  213. package/scripts/migrate.sh +135 -0
  214. package/scripts/prework_backup.sh +25 -0
  215. package/scripts/release.sh +83 -0
  216. package/tsconfig.json +25 -0
@@ -0,0 +1,459 @@
1
+ /**
2
+ * HTTP-level tests for document routes.
3
+ * Tests: GET /api/documents, GET /api/documents/:id,
4
+ * POST /api/documents, PATCH /api/documents/:id,
5
+ * DELETE /api/documents/:id
6
+ *
7
+ * Uses the shared integration harness for the mock infrastructure. Adds a
8
+ * temporary `as()` shim on Object.prototype so the document list query's
9
+ * `sql\`LEFT(...)\`.as("content")` call works against the harness's
10
+ * `sql` mock (which returns a plain object without `.as()`).
11
+ */
12
+
13
+ import {
14
+ afterAll,
15
+ afterEach,
16
+ beforeAll,
17
+ beforeEach,
18
+ describe,
19
+ expect,
20
+ it,
21
+ } from "bun:test";
22
+ import {
23
+ OWNER_ID,
24
+ OTHER_USER_ID,
25
+ getState,
26
+ noAuthHeaders,
27
+ ownerHeaders,
28
+ request,
29
+ resetState,
30
+ setupHarness,
31
+ } from "./_harness";
32
+
33
+ // The harness's `sql` mock returns plain `{ [Symbol(sql)]: true }` objects
34
+ // without an `.as()` method. The document list query does
35
+ // `sql\`LEFT(${documents.content}, 200)\`.as("content")` — calling `.as()`
36
+ // on a plain object throws. Install a no-op `.as()` on Object.prototype
37
+ // for the lifetime of this test file and remove it in afterAll so it
38
+ // doesn't leak into other test files.
39
+ (Object.prototype as any).as = function (this: any) {
40
+ return this;
41
+ };
42
+
43
+ let app: any;
44
+
45
+ beforeAll(async () => {
46
+ const built = await setupHarness();
47
+ app = built.app;
48
+ });
49
+
50
+ afterAll(() => {
51
+ delete (Object.prototype as any).as;
52
+ });
53
+
54
+ beforeEach(() => {
55
+ resetState();
56
+ });
57
+
58
+ afterEach(() => {
59
+ resetState();
60
+ });
61
+
62
+ function authedGet(path: string) {
63
+ return request(app, path, { method: "GET", headers: ownerHeaders() });
64
+ }
65
+
66
+ function authedPost(path: string, body: any) {
67
+ return request(app, path, {
68
+ method: "POST",
69
+ headers: ownerHeaders(),
70
+ body: JSON.stringify(body),
71
+ });
72
+ }
73
+
74
+ function authedPatch(path: string, body: any) {
75
+ return request(app, path, {
76
+ method: "PATCH",
77
+ headers: ownerHeaders(),
78
+ body: JSON.stringify(body),
79
+ });
80
+ }
81
+
82
+ function authedDelete(path: string) {
83
+ return request(app, path, { method: "DELETE", headers: ownerHeaders() });
84
+ }
85
+
86
+ function seedDocument(overrides: Partial<any> = {}): any {
87
+ const now = new Date("2024-06-01T00:00:00Z");
88
+ const doc = {
89
+ id: "00000000-0000-4000-8000-000000000000",
90
+ ownerId: OWNER_ID,
91
+ folderId: null,
92
+ title: "Seeded Doc",
93
+ content: "hello world",
94
+ contentJson: null,
95
+ metadata: null,
96
+ createdAt: now,
97
+ updatedAt: now,
98
+ ...overrides,
99
+ };
100
+ getState().documents.set(doc.id, doc);
101
+ return doc;
102
+ }
103
+
104
+ describe("GET /api/documents", () => {
105
+ it("returns 401 without auth", async () => {
106
+ const res = await request(app, "/api/documents", {
107
+ method: "GET",
108
+ headers: noAuthHeaders(),
109
+ });
110
+ expect(res.status).toBe(401);
111
+ expect(res.body).toEqual({ error: "Unauthorized" });
112
+ });
113
+
114
+ it("returns 200 with empty items when no documents exist", async () => {
115
+ const res = await authedGet("/api/documents");
116
+ expect(res.status).toBe(200);
117
+ expect((res.body as any).items).toEqual([]);
118
+ expect((res.body as any).page).toBe(1);
119
+ expect((res.body as any).limit).toBe(20);
120
+ });
121
+
122
+ it("returns only the current user's documents", async () => {
123
+ seedDocument({ id: "11111111-1111-4111-8111-111111111111", title: "Mine A" });
124
+ seedDocument({ id: "22222222-2222-4222-8222-222222222222", title: "Mine B" });
125
+ seedDocument({
126
+ id: "33333333-3333-4333-8333-333333333333",
127
+ title: "Theirs",
128
+ ownerId: OTHER_USER_ID,
129
+ });
130
+
131
+ const res = await authedGet("/api/documents");
132
+ expect(res.status).toBe(200);
133
+ const items = (res.body as any).items as Array<{ id: string; title: string }>;
134
+ const ids = items.map((d) => d.id);
135
+ expect(ids).toContain("11111111-1111-4111-8111-111111111111");
136
+ expect(ids).toContain("22222222-2222-4222-8222-222222222222");
137
+ expect(ids).not.toContain("33333333-3333-4333-8333-333333333333");
138
+ });
139
+
140
+ it("respects the page and limit query parameters", async () => {
141
+ for (let i = 0; i < 5; i++) {
142
+ seedDocument({
143
+ id: `${i.toString().padStart(8, "0")}-0000-4000-8000-000000000000`,
144
+ title: `Doc ${i}`,
145
+ });
146
+ }
147
+
148
+ const res = await authedGet("/api/documents?page=2&limit=2");
149
+ expect(res.status).toBe(200);
150
+ expect((res.body as any).page).toBe(2);
151
+ expect((res.body as any).limit).toBe(2);
152
+ expect(((res.body as any).items as any[]).length).toBe(2);
153
+ });
154
+
155
+ it("rejects limit above 100", async () => {
156
+ const res = await authedGet("/api/documents?limit=500");
157
+ expect(res.status).toBe(400);
158
+ expect((res.body as any).error).toBe("Invalid query");
159
+ });
160
+
161
+ it("rejects a non-uuid folderId filter", async () => {
162
+ const res = await authedGet("/api/documents?folderId=not-a-uuid");
163
+ expect(res.status).toBe(400);
164
+ expect((res.body as any).error).toBe("Invalid query");
165
+ });
166
+
167
+ it("filters by folderId when provided", async () => {
168
+ const folderId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
169
+ seedDocument({
170
+ id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
171
+ title: "In folder",
172
+ folderId,
173
+ });
174
+ seedDocument({
175
+ id: "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
176
+ title: "Outside folder",
177
+ folderId: null,
178
+ });
179
+
180
+ const res = await authedGet(`/api/documents?folderId=${folderId}`);
181
+ expect(res.status).toBe(200);
182
+ const items = (res.body as any).items as Array<{ id: string }>;
183
+ const ids = items.map((d) => d.id);
184
+ expect(ids).toContain("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb");
185
+ expect(ids).not.toContain("cccccccc-cccc-4ccc-8ccc-cccccccccccc");
186
+ });
187
+ });
188
+
189
+ describe("GET /api/documents/:id", () => {
190
+ it("returns 404 for an unknown id", async () => {
191
+ const res = await authedGet("/api/documents/00000000-0000-4000-8000-000000000099");
192
+ expect(res.status).toBe(404);
193
+ expect(res.body).toEqual({ error: "Document not found" });
194
+ });
195
+
196
+ it("returns 200 with the document and tags for an owned doc", async () => {
197
+ const doc = seedDocument({
198
+ id: "44444444-4444-4444-8444-444444444444",
199
+ title: "My Doc",
200
+ });
201
+
202
+ const res = await authedGet(`/api/documents/${doc.id}`);
203
+ expect(res.status).toBe(200);
204
+ expect((res.body as any).id).toBe(doc.id);
205
+ expect((res.body as any).title).toBe("My Doc");
206
+ expect((res.body as any).ownerId).toBe(OWNER_ID);
207
+ expect(Array.isArray((res.body as any).tags)).toBe(true);
208
+ });
209
+
210
+ it("returns 404 for a document owned by another user", async () => {
211
+ seedDocument({
212
+ id: "55555555-5555-4555-8555-555555555555",
213
+ title: "Other's Doc",
214
+ ownerId: OTHER_USER_ID,
215
+ });
216
+
217
+ const res = await authedGet(
218
+ "/api/documents/55555555-5555-4555-8555-555555555555",
219
+ );
220
+ expect(res.status).toBe(404);
221
+ expect(res.body).toEqual({ error: "Document not found" });
222
+ });
223
+
224
+ it("returns 401 with an invalid bearer token", async () => {
225
+ const doc = seedDocument({ id: "66666666-6666-4666-8666-666666666666" });
226
+ const res = await request(app, `/api/documents/${doc.id}`, {
227
+ method: "GET",
228
+ headers: {
229
+ authorization: "Bearer wrong-api-key",
230
+ "content-type": "application/json",
231
+ },
232
+ });
233
+ expect(res.status).toBe(401);
234
+ });
235
+ });
236
+
237
+ describe("POST /api/documents", () => {
238
+ it("returns 403 from CSRF middleware on POST without Bearer or CSRF token", async () => {
239
+ const res = await request(app, "/api/documents", {
240
+ method: "POST",
241
+ headers: noAuthHeaders(),
242
+ body: JSON.stringify({ title: "Test" }),
243
+ });
244
+ expect(res.status).toBe(403);
245
+ expect((res.body as any).error).toMatch(/CSRF/i);
246
+ });
247
+
248
+ it("returns 400 when title is empty", async () => {
249
+ const res = await authedPost("/api/documents", { title: "" });
250
+ expect(res.status).toBe(400);
251
+ expect((res.body as any).error).toBe("Invalid input");
252
+ });
253
+
254
+ it("returns 400 when title exceeds 500 chars", async () => {
255
+ const res = await authedPost("/api/documents", { title: "a".repeat(501) });
256
+ expect(res.status).toBe(400);
257
+ expect((res.body as any).error).toBe("Invalid input");
258
+ });
259
+
260
+ it("returns 400 for an invalid folderId", async () => {
261
+ const res = await authedPost("/api/documents", {
262
+ title: "Bad folder",
263
+ folderId: "not-a-uuid",
264
+ });
265
+ expect(res.status).toBe(400);
266
+ expect((res.body as any).error).toBe("Invalid input");
267
+ });
268
+
269
+ it("creates a document and returns 201", async () => {
270
+ const res = await authedPost("/api/documents", {
271
+ title: "My first doc",
272
+ content: "# Hello",
273
+ });
274
+ expect(res.status).toBe(201);
275
+ const body = res.body as { id: string; title: string; ownerId: string };
276
+ expect(body.title).toBe("My first doc");
277
+ expect(body.ownerId).toBe(OWNER_ID);
278
+ expect(body.id).toBeTruthy();
279
+
280
+ // Verify the document is in the state
281
+ const stored = getState().documents.get(body.id);
282
+ expect(stored).toBeTruthy();
283
+ expect((stored as any).title).toBe("My first doc");
284
+ expect((stored as any).ownerId).toBe(OWNER_ID);
285
+
286
+ // Embedding should have been enqueued
287
+ expect(getState().enqueuedEmbeddings).toContain(body.id);
288
+ });
289
+
290
+ it("defaults the title to 'Untitled' when omitted", async () => {
291
+ const res = await authedPost("/api/documents", {});
292
+ expect(res.status).toBe(201);
293
+ expect((res.body as any).title).toBe("Untitled");
294
+ });
295
+
296
+ it("returns 401 with an invalid bearer token", async () => {
297
+ const res = await request(app, "/api/documents", {
298
+ method: "POST",
299
+ headers: {
300
+ authorization: "Bearer wrong-api-key",
301
+ "content-type": "application/json",
302
+ },
303
+ body: JSON.stringify({ title: "Nope" }),
304
+ });
305
+ expect(res.status).toBe(401);
306
+ });
307
+ });
308
+
309
+ describe("PATCH /api/documents/:id", () => {
310
+ it("returns 404 for an unknown document", async () => {
311
+ const res = await authedPatch(
312
+ "/api/documents/00000000-0000-4000-8000-000000000099",
313
+ { title: "Renamed" },
314
+ );
315
+ expect(res.status).toBe(404);
316
+ expect(res.body).toEqual({ error: "Document not found" });
317
+ });
318
+
319
+ it("returns 400 when no fields are provided", async () => {
320
+ const doc = seedDocument({ id: "77777777-7777-4777-8777-777777777777" });
321
+ const res = await authedPatch(`/api/documents/${doc.id}`, {});
322
+ expect(res.status).toBe(400);
323
+ expect((res.body as any).error).toMatch(/at least one field/i);
324
+ });
325
+
326
+ it("returns 400 for an invalid title", async () => {
327
+ const doc = seedDocument({ id: "88888888-8888-4888-8888-888888888888" });
328
+ const res = await authedPatch(`/api/documents/${doc.id}`, { title: "" });
329
+ expect(res.status).toBe(400);
330
+ expect((res.body as any).error).toBe("Invalid input");
331
+ });
332
+
333
+ it("renames a document", async () => {
334
+ const doc = seedDocument({
335
+ id: "99999999-9999-4999-8999-999999999999",
336
+ title: "Old",
337
+ });
338
+
339
+ const res = await authedPatch(`/api/documents/${doc.id}`, {
340
+ title: "New",
341
+ });
342
+ expect(res.status).toBe(200);
343
+ expect((res.body as any).title).toBe("New");
344
+
345
+ const stored = getState().documents.get(doc.id) as any;
346
+ expect(stored.title).toBe("New");
347
+ });
348
+
349
+ it("updates content and re-enqueues embedding", async () => {
350
+ const doc = seedDocument({
351
+ id: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
352
+ title: "Keep",
353
+ content: "old body",
354
+ });
355
+
356
+ const res = await authedPatch(`/api/documents/${doc.id}`, {
357
+ content: "new body",
358
+ });
359
+ expect(res.status).toBe(200);
360
+
361
+ const stored = getState().documents.get(doc.id) as any;
362
+ expect(stored.content).toBe("new body");
363
+
364
+ // Embedding should have been re-enqueued
365
+ expect(getState().enqueuedEmbeddings).toContain(doc.id);
366
+ });
367
+
368
+ it("moves a document to a different folder", async () => {
369
+ const doc = seedDocument({
370
+ id: "abcdef00-0000-4000-8000-000000000000",
371
+ folderId: null,
372
+ });
373
+ const newFolder = "abcdef01-0000-4000-8000-000000000000";
374
+
375
+ const res = await authedPatch(`/api/documents/${doc.id}`, {
376
+ folderId: newFolder,
377
+ });
378
+ expect(res.status).toBe(200);
379
+ expect((res.body as any).folderId).toBe(newFolder);
380
+ });
381
+
382
+ it("returns 404 when updating a document owned by another user", async () => {
383
+ seedDocument({
384
+ id: "abcdef02-0000-4000-8000-000000000000",
385
+ ownerId: OTHER_USER_ID,
386
+ });
387
+ const res = await authedPatch(
388
+ "/api/documents/abcdef02-0000-4000-8000-000000000000",
389
+ { title: "X" },
390
+ );
391
+ expect(res.status).toBe(404);
392
+ expect(res.body).toEqual({ error: "Document not found" });
393
+ });
394
+ });
395
+
396
+ describe("DELETE /api/documents/:id", () => {
397
+ it("returns 404 for an unknown document", async () => {
398
+ const res = await authedDelete(
399
+ "/api/documents/00000000-0000-4000-8000-000000000099",
400
+ );
401
+ expect(res.status).toBe(404);
402
+ expect(res.body).toEqual({ error: "Document not found" });
403
+ });
404
+
405
+ it("deletes an owned document", async () => {
406
+ const doc = seedDocument({ id: "deadbeef-0000-4000-8000-000000000000" });
407
+ const res = await authedDelete(`/api/documents/${doc.id}`);
408
+ expect(res.status).toBe(200);
409
+ expect(res.body).toEqual({ success: true });
410
+ expect(getState().documents.has(doc.id)).toBe(false);
411
+ });
412
+
413
+ it("does not delete a document owned by another user", async () => {
414
+ const doc = seedDocument({
415
+ id: "deadbee1-0000-4000-8000-000000000000",
416
+ ownerId: OTHER_USER_ID,
417
+ });
418
+ const res = await authedDelete(`/api/documents/${doc.id}`);
419
+ expect(res.status).toBe(404);
420
+ expect(getState().documents.has(doc.id)).toBe(true);
421
+ });
422
+
423
+ it("returns 401 with an invalid bearer token", async () => {
424
+ const doc = seedDocument({ id: "deadbee2-0000-4000-8000-000000000000" });
425
+ const res = await request(app, `/api/documents/${doc.id}`, {
426
+ method: "DELETE",
427
+ headers: {
428
+ authorization: "Bearer wrong-api-key",
429
+ "content-type": "application/json",
430
+ },
431
+ });
432
+ expect(res.status).toBe(401);
433
+ });
434
+ });
435
+
436
+ describe("Document API auth integration", () => {
437
+ it("uses OWNER_ID when the test API key is presented", async () => {
438
+ const create = await authedPost("/api/documents", { title: "API key flow" });
439
+ expect(create.status).toBe(201);
440
+ expect((create.body as any).ownerId).toBe(OWNER_ID);
441
+
442
+ const list = await authedGet("/api/documents");
443
+ expect(list.status).toBe(200);
444
+ const items = (list.body as any).items as Array<{ id: string }>;
445
+ const found = items.find((d) => d.id === (create.body as any).id);
446
+ expect(found).toBeTruthy();
447
+ });
448
+
449
+ it("rejects a wrong Bearer token with 401", async () => {
450
+ const res = await request(app, "/api/documents", {
451
+ method: "GET",
452
+ headers: {
453
+ authorization: "Bearer wrong-api-key",
454
+ "content-type": "application/json",
455
+ },
456
+ });
457
+ expect(res.status).toBe(401);
458
+ });
459
+ });