Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request 'test: add server tests for api-v1 routes and AI proxy' (#347) from test/server-coverage-gaps into main

scott e76d76bb 8d70416e

+782
+433
tests/server/ai.test.ts
··· 1 + /** 2 + * Tests for AI proxy routes and release info endpoint. 3 + * Mocks global fetch to avoid hitting real external services. 4 + */ 5 + 6 + import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from 'vitest'; 7 + import { createServer } from 'http'; 8 + import express from 'express'; 9 + import type { Server } from 'http'; 10 + 11 + let baseUrl: string; 12 + let server: Server; 13 + 14 + // Save real fetch before mocking — tests use realFetch for local HTTP, 15 + // while route handlers inside Express see the mocked global fetch. 16 + const realFetch = globalThis.fetch; 17 + const mockFetch = vi.fn(); 18 + 19 + type Req = express.Request & { tsUser?: { login: string; name: string; profilePic: string | null } | null }; 20 + type Res = express.Response; 21 + 22 + beforeAll(async () => { 23 + // Replace global fetch so route handlers call our mock for upstream 24 + vi.stubGlobal('fetch', mockFetch); 25 + 26 + const AI_GATEWAY_URL = 'https://ai.test.local'; 27 + 28 + // Inline rate limiter (mirrors server/validation.ts) 29 + const rateLimitMap = new Map<string, { count: number; resetAt: number }>(); 30 + function rlCheck(key: string, max: number, windowMs: number): boolean { 31 + const now = Date.now(); 32 + const entry = rateLimitMap.get(key); 33 + if (!entry || now >= entry.resetAt) { 34 + rateLimitMap.set(key, { count: 1, resetAt: now + windowMs }); 35 + return true; 36 + } 37 + if (entry.count >= max) return false; 38 + entry.count++; 39 + return true; 40 + } 41 + 42 + // Inline sanitizer (mirrors server/validation.ts) 43 + const AI_ALLOWED_KEYS = ['model', 'messages', 'temperature', 'max_tokens', 'stream', 'top_p', 'stop', 'presence_penalty', 'frequency_penalty'] as const; 44 + function sanitizeAiRequest(body: unknown): Record<string, unknown> | null { 45 + if (!body || typeof body !== 'object' || Array.isArray(body)) return null; 46 + const sanitized: Record<string, unknown> = {}; 47 + for (const key of AI_ALLOWED_KEYS) { 48 + if (key in (body as Record<string, unknown>)) { 49 + sanitized[key] = (body as Record<string, unknown>)[key]; 50 + } 51 + } 52 + if (!sanitized.messages || !Array.isArray(sanitized.messages)) return null; 53 + return sanitized; 54 + } 55 + 56 + const app = express(); 57 + app.use(express.json({ limit: '1mb' })); 58 + 59 + // Simulate Tailscale auth middleware 60 + app.use((req: Req, _res, next) => { 61 + const login = req.headers['tailscale-user-login'] as string | undefined; 62 + if (login) { 63 + req.tsUser = { login, name: login, profilePic: null }; 64 + } else { 65 + req.tsUser = null; 66 + } 67 + next(); 68 + }); 69 + 70 + // Mirror production AI proxy routes 71 + app.post('/api/ai/chat/completions', async (req: Req, res: Res) => { 72 + const rlKey = `ai:${req.tsUser?.login || 'anon'}`; 73 + if (!rlCheck(rlKey, 30, 60000)) { 74 + res.status(429).json({ error: 'AI rate limit exceeded, please slow down' }); 75 + return; 76 + } 77 + 78 + const gatewayUrl = `${AI_GATEWAY_URL}/v1/chat/completions`; 79 + try { 80 + const sanitized = sanitizeAiRequest(req.body); 81 + if (!sanitized) { 82 + res.status(400).json({ error: 'messages array is required' }); 83 + return; 84 + } 85 + 86 + const upstream = await fetch(gatewayUrl, { 87 + method: 'POST', 88 + headers: { 'Content-Type': 'application/json' }, 89 + body: JSON.stringify(sanitized), 90 + }); 91 + 92 + if (!upstream.ok) { 93 + const errText = await upstream.text(); 94 + res.status(upstream.status).send(errText); 95 + return; 96 + } 97 + 98 + res.status(upstream.status); 99 + for (const [key, value] of upstream.headers.entries()) { 100 + if (['content-type', 'transfer-encoding'].includes(key.toLowerCase())) { 101 + res.set(key, value); 102 + } 103 + } 104 + 105 + if (upstream.body) { 106 + const reader = (upstream.body as ReadableStream<Uint8Array>).getReader(); 107 + for (;;) { 108 + const { done, value } = await reader.read(); 109 + if (done) break; 110 + res.write(value); 111 + } 112 + res.end(); 113 + } else { 114 + res.end(); 115 + } 116 + } catch (err: unknown) { 117 + const message = err instanceof Error ? err.message : 'Unknown error'; 118 + res.status(502).json({ error: { message: `AI gateway unreachable: ${message}` } }); 119 + } 120 + }); 121 + 122 + app.get('/api/ai/models', async (_req: Req, res: Res) => { 123 + try { 124 + const upstream = await fetch(`${AI_GATEWAY_URL}/v1/models`); 125 + if (!upstream.ok) { 126 + res.status(upstream.status).send(await upstream.text()); 127 + return; 128 + } 129 + res.json(await upstream.json()); 130 + } catch (err: unknown) { 131 + const message = err instanceof Error ? err.message : 'Unknown error'; 132 + res.status(502).json({ error: { message: `AI gateway unreachable: ${message}` } }); 133 + } 134 + }); 135 + 136 + // Release info with cache — exposed via test-only reset endpoint 137 + let releaseCache: { data: unknown; expires: number } | null = null; 138 + const GITEA_RELEASES_URL = 'https://gitea.test.local/api/v1/repos/test/tools/releases'; 139 + 140 + app.post('/test/reset-release-cache', (_req: Req, res: Res) => { 141 + releaseCache = null; 142 + res.json({ ok: true }); 143 + }); 144 + 145 + app.get('/api/releases/latest', async (_req: Req, res: Res) => { 146 + try { 147 + const now = Date.now(); 148 + if (releaseCache && now < releaseCache.expires) { 149 + return res.json(releaseCache.data); 150 + } 151 + const upstream = await fetch(`${GITEA_RELEASES_URL}?limit=1`, { signal: AbortSignal.timeout(5000) }); 152 + if (!upstream.ok) return res.json({ available: false }); 153 + const releases = await upstream.json() as Array<{ 154 + tag_name: string; name: string; html_url: string; 155 + assets: Array<{ name: string; browser_download_url: string; size: number }>; 156 + }>; 157 + const release = releases[0]; 158 + if (!release) return res.json({ available: false }); 159 + const dmg = release.assets?.find((a: { name: string }) => a.name.endsWith('.dmg')); 160 + const result = { 161 + available: true, 162 + version: release.tag_name, 163 + name: release.name || release.tag_name, 164 + url: release.html_url, 165 + dmg: dmg ? { url: dmg.browser_download_url, size: dmg.size } : null, 166 + }; 167 + releaseCache = { data: result, expires: now + 5 * 60 * 1000 }; 168 + res.json(result); 169 + } catch { 170 + res.json({ available: false }); 171 + } 172 + }); 173 + 174 + server = createServer(app); 175 + await new Promise<void>((resolve) => { 176 + server.listen(0, () => { 177 + const addr = server.address(); 178 + baseUrl = `http://localhost:${typeof addr === 'object' && addr ? addr.port : 0}`; 179 + resolve(); 180 + }); 181 + }); 182 + }); 183 + 184 + afterAll(() => { 185 + server?.close(); 186 + vi.unstubAllGlobals(); 187 + }); 188 + 189 + beforeEach(async () => { 190 + mockFetch.mockReset(); 191 + // Clear release cache between tests so each test gets fresh upstream calls 192 + await realFetch(`${baseUrl}/test/reset-release-cache`, { method: 'POST' }); 193 + }); 194 + 195 + // --- Helpers --- 196 + 197 + function mockUpstreamResponse(body: unknown, status = 200, headers: Record<string, string> = {}) { 198 + const responseHeaders = new Headers({ 'content-type': 'application/json', ...headers }); 199 + const responseBody = JSON.stringify(body); 200 + const encoder = new TextEncoder(); 201 + const stream = new ReadableStream({ 202 + start(controller) { 203 + controller.enqueue(encoder.encode(responseBody)); 204 + controller.close(); 205 + }, 206 + }); 207 + 208 + mockFetch.mockResolvedValueOnce(new Response(stream, { status, headers: responseHeaders })); 209 + } 210 + 211 + function authHeaders(login: string): Record<string, string> { 212 + return { 'tailscale-user-login': login, 'tailscale-user-name': login }; 213 + } 214 + 215 + // --- AI Chat Completions Tests --- 216 + 217 + describe('POST /api/ai/chat/completions', () => { 218 + it('proxies valid request to upstream', async () => { 219 + const upstreamReply = { choices: [{ message: { content: 'Hello!' } }] }; 220 + mockUpstreamResponse(upstreamReply); 221 + 222 + const res = await realFetch(`${baseUrl}/api/ai/chat/completions`, { 223 + method: 'POST', 224 + headers: { 'Content-Type': 'application/json', ...authHeaders('test@user') }, 225 + body: JSON.stringify({ 226 + model: 'gpt-4', 227 + messages: [{ role: 'user', content: 'Hi' }], 228 + }), 229 + }); 230 + 231 + expect(res.status).toBe(200); 232 + const body = await res.text(); 233 + expect(body).toContain('Hello!'); 234 + 235 + // Verify upstream was called with sanitized request 236 + expect(mockFetch).toHaveBeenCalledWith( 237 + 'https://ai.test.local/v1/chat/completions', 238 + expect.objectContaining({ 239 + method: 'POST', 240 + }), 241 + ); 242 + }); 243 + 244 + it('rejects request without messages', async () => { 245 + const res = await realFetch(`${baseUrl}/api/ai/chat/completions`, { 246 + method: 'POST', 247 + headers: { 'Content-Type': 'application/json' }, 248 + body: JSON.stringify({ model: 'gpt-4' }), 249 + }); 250 + expect(res.status).toBe(400); 251 + const body = await res.json() as { error: string }; 252 + expect(body.error).toContain('messages'); 253 + }); 254 + 255 + it('rejects non-array messages', async () => { 256 + const res = await realFetch(`${baseUrl}/api/ai/chat/completions`, { 257 + method: 'POST', 258 + headers: { 'Content-Type': 'application/json' }, 259 + body: JSON.stringify({ messages: 'not-an-array' }), 260 + }); 261 + expect(res.status).toBe(400); 262 + }); 263 + 264 + it('strips disallowed fields from request', async () => { 265 + mockUpstreamResponse({ choices: [] }); 266 + 267 + await realFetch(`${baseUrl}/api/ai/chat/completions`, { 268 + method: 'POST', 269 + headers: { 'Content-Type': 'application/json' }, 270 + body: JSON.stringify({ 271 + model: 'gpt-4', 272 + messages: [{ role: 'user', content: 'Hi' }], 273 + dangerous_field: 'should-be-stripped', 274 + api_key: 'should-be-stripped', 275 + }), 276 + }); 277 + 278 + const upstreamCall = mockFetch.mock.calls[0]; 279 + const sentBody = JSON.parse(upstreamCall[1].body as string); 280 + expect(sentBody.dangerous_field).toBeUndefined(); 281 + expect(sentBody.api_key).toBeUndefined(); 282 + expect(sentBody.model).toBe('gpt-4'); 283 + expect(sentBody.messages).toBeDefined(); 284 + }); 285 + 286 + it('forwards upstream error status', async () => { 287 + mockFetch.mockResolvedValueOnce(new Response('Upstream error', { 288 + status: 500, 289 + headers: { 'content-type': 'text/plain' }, 290 + })); 291 + 292 + const res = await realFetch(`${baseUrl}/api/ai/chat/completions`, { 293 + method: 'POST', 294 + headers: { 'Content-Type': 'application/json' }, 295 + body: JSON.stringify({ messages: [{ role: 'user', content: 'Hi' }] }), 296 + }); 297 + expect(res.status).toBe(500); 298 + }); 299 + 300 + it('returns 502 when upstream is unreachable', async () => { 301 + mockFetch.mockRejectedValueOnce(new Error('ECONNREFUSED')); 302 + 303 + const res = await realFetch(`${baseUrl}/api/ai/chat/completions`, { 304 + method: 'POST', 305 + headers: { 'Content-Type': 'application/json' }, 306 + body: JSON.stringify({ messages: [{ role: 'user', content: 'Hi' }] }), 307 + }); 308 + expect(res.status).toBe(502); 309 + const body = await res.json() as { error: { message: string } }; 310 + expect(body.error.message).toContain('unreachable'); 311 + }); 312 + 313 + it('rate limits after 30 requests per minute', async () => { 314 + // First 30 requests should succeed (or fail upstream, but not 429) 315 + for (let i = 0; i < 30; i++) { 316 + mockUpstreamResponse({ choices: [] }); 317 + } 318 + 319 + for (let i = 0; i < 30; i++) { 320 + await realFetch(`${baseUrl}/api/ai/chat/completions`, { 321 + method: 'POST', 322 + headers: { 'Content-Type': 'application/json', ...authHeaders('ratelimit-user') }, 323 + body: JSON.stringify({ messages: [{ role: 'user', content: `msg${i}` }] }), 324 + }); 325 + } 326 + 327 + // 31st request should be rate limited 328 + const res = await realFetch(`${baseUrl}/api/ai/chat/completions`, { 329 + method: 'POST', 330 + headers: { 'Content-Type': 'application/json', ...authHeaders('ratelimit-user') }, 331 + body: JSON.stringify({ messages: [{ role: 'user', content: 'too many' }] }), 332 + }); 333 + expect(res.status).toBe(429); 334 + }); 335 + }); 336 + 337 + // --- AI Models Tests --- 338 + 339 + describe('GET /api/ai/models', () => { 340 + it('proxies model list from upstream', async () => { 341 + const models = { data: [{ id: 'gpt-4' }, { id: 'claude-3' }] }; 342 + mockUpstreamResponse(models); 343 + 344 + const res = await realFetch(`${baseUrl}/api/ai/models`); 345 + expect(res.status).toBe(200); 346 + const body = await res.json() as { data: Array<{ id: string }> }; 347 + expect(body.data.length).toBe(2); 348 + }); 349 + 350 + it('forwards upstream error', async () => { 351 + mockFetch.mockResolvedValueOnce(new Response('Service unavailable', { 352 + status: 503, 353 + headers: { 'content-type': 'text/plain' }, 354 + })); 355 + 356 + const res = await realFetch(`${baseUrl}/api/ai/models`); 357 + expect(res.status).toBe(503); 358 + }); 359 + 360 + it('returns 502 when gateway is unreachable', async () => { 361 + mockFetch.mockRejectedValueOnce(new Error('ECONNREFUSED')); 362 + 363 + const res = await realFetch(`${baseUrl}/api/ai/models`); 364 + expect(res.status).toBe(502); 365 + const body = await res.json() as { error: { message: string } }; 366 + expect(body.error.message).toContain('unreachable'); 367 + }); 368 + }); 369 + 370 + // --- Release Info Tests --- 371 + 372 + describe('GET /api/releases/latest', () => { 373 + it('returns release info with .dmg asset', async () => { 374 + const releases = [{ 375 + tag_name: 'v0.27.0', 376 + name: 'Tools v0.27.0', 377 + html_url: 'https://gitea.test.local/releases/v0.27.0', 378 + assets: [ 379 + { name: 'Tools-0.27.0-arm64.dmg', browser_download_url: 'https://gitea.test.local/dl/tools.dmg', size: 100000 }, 380 + ], 381 + }]; 382 + mockUpstreamResponse(releases); 383 + 384 + const res = await realFetch(`${baseUrl}/api/releases/latest`); 385 + expect(res.status).toBe(200); 386 + const body = await res.json() as { available: boolean; version: string; dmg: { url: string; size: number } | null }; 387 + expect(body.available).toBe(true); 388 + expect(body.version).toBe('v0.27.0'); 389 + expect(body.dmg).not.toBeNull(); 390 + expect(body.dmg!.size).toBe(100000); 391 + }); 392 + 393 + it('returns available false when no releases exist', async () => { 394 + mockUpstreamResponse([]); 395 + 396 + const res = await realFetch(`${baseUrl}/api/releases/latest`); 397 + const body = await res.json() as { available: boolean }; 398 + expect(body.available).toBe(false); 399 + }); 400 + 401 + it('returns available false when upstream fails', async () => { 402 + mockFetch.mockResolvedValueOnce(new Response('error', { status: 500 })); 403 + 404 + const res = await realFetch(`${baseUrl}/api/releases/latest`); 405 + const body = await res.json() as { available: boolean }; 406 + expect(body.available).toBe(false); 407 + }); 408 + 409 + it('returns available false when upstream is unreachable', async () => { 410 + mockFetch.mockRejectedValueOnce(new Error('ECONNREFUSED')); 411 + 412 + const res = await realFetch(`${baseUrl}/api/releases/latest`); 413 + const body = await res.json() as { available: boolean }; 414 + expect(body.available).toBe(false); 415 + }); 416 + 417 + it('returns release without dmg when no .dmg asset present', async () => { 418 + const releases = [{ 419 + tag_name: 'v0.27.0', 420 + name: 'Tools v0.27.0', 421 + html_url: 'https://gitea.test.local/releases/v0.27.0', 422 + assets: [ 423 + { name: 'Tools-0.27.0.tar.gz', browser_download_url: 'https://gitea.test.local/dl/tools.tar.gz', size: 50000 }, 424 + ], 425 + }]; 426 + mockUpstreamResponse(releases); 427 + 428 + const res = await realFetch(`${baseUrl}/api/releases/latest`); 429 + const body = await res.json() as { available: boolean; dmg: null }; 430 + expect(body.available).toBe(true); 431 + expect(body.dmg).toBeNull(); 432 + }); 433 + });
+349
tests/server/api-v1.test.ts
··· 1 + /** 2 + * Tests for API v1 routes: paginated document listing and batch resolve. 3 + * Uses an in-memory SQLite database mirroring the production schema. 4 + */ 5 + 6 + import { describe, it, expect, beforeAll, afterAll } from 'vitest'; 7 + import Database from 'better-sqlite3'; 8 + import { randomUUID } from 'crypto'; 9 + import { createServer } from 'http'; 10 + import express from 'express'; 11 + import type { Server } from 'http'; 12 + 13 + let baseUrl: string; 14 + let server: Server; 15 + let db: ReturnType<typeof Database>; 16 + 17 + type Req = express.Request; 18 + type Res = express.Response; 19 + 20 + beforeAll(async () => { 21 + db = new Database(':memory:'); 22 + db.pragma('journal_mode = WAL'); 23 + 24 + db.exec(` 25 + CREATE TABLE documents ( 26 + id TEXT PRIMARY KEY, 27 + type TEXT NOT NULL CHECK(type IN ('doc','sheet','form','slide','diagram','calendar')), 28 + name_encrypted TEXT, 29 + snapshot BLOB, 30 + share_mode TEXT DEFAULT 'edit', 31 + expires_at TEXT, 32 + deleted_at TEXT, 33 + tags TEXT, 34 + owner TEXT, 35 + created_at TEXT DEFAULT (datetime('now')), 36 + updated_at TEXT DEFAULT (datetime('now')) 37 + ) 38 + `); 39 + 40 + const VALID_TYPES = ['doc', 'sheet', 'form', 'slide', 'diagram', 'calendar'] as const; 41 + 42 + const listAllStmt = db.prepare( 43 + 'SELECT id, type, name_encrypted, created_at, updated_at FROM documents WHERE deleted_at IS NULL ORDER BY updated_at DESC LIMIT ? OFFSET ?' 44 + ); 45 + const listByTypeStmt = db.prepare( 46 + 'SELECT id, type, name_encrypted, created_at, updated_at FROM documents WHERE deleted_at IS NULL AND type = ? ORDER BY updated_at DESC LIMIT ? OFFSET ?' 47 + ); 48 + const countStmt = db.prepare('SELECT COUNT(*) as count FROM documents WHERE deleted_at IS NULL'); 49 + const getOneStmt = db.prepare('SELECT id, type, name_encrypted, created_at, updated_at FROM documents WHERE id = ?'); 50 + 51 + const resolveStmtCache = new Map<number, ReturnType<typeof db.prepare>>(); 52 + function getResolveStmt(count: number) { 53 + let stmt = resolveStmtCache.get(count); 54 + if (!stmt) { 55 + const placeholders = Array.from({ length: count }, () => '?').join(','); 56 + stmt = db.prepare(`SELECT id, type, name_encrypted, updated_at FROM documents WHERE id IN (${placeholders}) AND deleted_at IS NULL`); 57 + resolveStmtCache.set(count, stmt); 58 + } 59 + return stmt; 60 + } 61 + 62 + const app = express(); 63 + app.use(express.json({ limit: '1mb' })); 64 + 65 + // Seed helper route (not in production — just for tests) 66 + app.post('/test/seed', (req: Req, res: Res) => { 67 + const { id, type, name_encrypted, deleted_at } = req.body; 68 + db.prepare( 69 + 'INSERT INTO documents (id, type, name_encrypted, deleted_at) VALUES (?, ?, ?, ?)' 70 + ).run(id, type, name_encrypted || null, deleted_at || null); 71 + res.json({ ok: true }); 72 + }); 73 + 74 + // Mirror production api-v1 routes 75 + app.get('/api/v1/documents', (req: Req, res: Res) => { 76 + const { type, limit: lim, offset: off } = req.query; 77 + const limit = Math.min(Math.max(parseInt(lim as string) || 50, 1), 200); 78 + const offset = Math.max(parseInt(off as string) || 0, 0); 79 + 80 + const rows = (type && VALID_TYPES.includes(type as typeof VALID_TYPES[number])) 81 + ? listByTypeStmt.all(type, limit, offset) 82 + : listAllStmt.all(limit, offset); 83 + 84 + const total = (countStmt.get() as { count: number }).count; 85 + res.json({ data: rows, total, limit, offset }); 86 + }); 87 + 88 + app.get('/api/v1/documents/:id', (req: Req, res: Res) => { 89 + const row = getOneStmt.get(req.params.id); 90 + if (!row) return res.status(404).json({ error: 'Not found' }); 91 + res.json(row); 92 + }); 93 + 94 + app.post('/api/v1/documents/resolve', (req: Req, res: Res) => { 95 + const { ids } = req.body; 96 + if (!Array.isArray(ids) || ids.length === 0) return res.status(400).json({ error: 'ids array required' }); 97 + const capped = ids.slice(0, 50).filter((id: unknown) => typeof id === 'string' && (id as string).length > 0); 98 + if (capped.length === 0) return res.status(400).json({ error: 'ids must be non-empty strings' }); 99 + const rows = getResolveStmt(capped.length).all(capped); 100 + res.json({ data: rows }); 101 + }); 102 + 103 + server = createServer(app); 104 + await new Promise<void>((resolve) => { 105 + server.listen(0, () => { 106 + const addr = server.address(); 107 + baseUrl = `http://localhost:${typeof addr === 'object' && addr ? addr.port : 0}`; 108 + resolve(); 109 + }); 110 + }); 111 + }); 112 + 113 + afterAll(() => { 114 + server?.close(); 115 + db?.close(); 116 + }); 117 + 118 + // --- Helpers --- 119 + 120 + async function seedDoc(type: string, opts: { name?: string; deleted?: boolean } = {}): Promise<string> { 121 + const id = randomUUID(); 122 + await fetch(`${baseUrl}/test/seed`, { 123 + method: 'POST', 124 + headers: { 'Content-Type': 'application/json' }, 125 + body: JSON.stringify({ 126 + id, 127 + type, 128 + name_encrypted: opts.name || null, 129 + deleted_at: opts.deleted ? '2026-01-01 00:00:00' : null, 130 + }), 131 + }); 132 + return id; 133 + } 134 + 135 + // --- Tests --- 136 + 137 + describe('GET /api/v1/documents', () => { 138 + it('returns paginated results with defaults', async () => { 139 + const id = await seedDoc('doc', { name: 'test' }); 140 + const res = await fetch(`${baseUrl}/api/v1/documents`); 141 + expect(res.status).toBe(200); 142 + const body = await res.json() as { data: unknown[]; total: number; limit: number; offset: number }; 143 + expect(body.limit).toBe(50); 144 + expect(body.offset).toBe(0); 145 + expect(body.total).toBeGreaterThanOrEqual(1); 146 + expect(body.data.length).toBeGreaterThanOrEqual(1); 147 + expect(body.data.some((d: any) => d.id === id)).toBe(true); 148 + }); 149 + 150 + it('respects limit parameter', async () => { 151 + // Seed enough docs to test limiting 152 + await seedDoc('doc'); 153 + await seedDoc('doc'); 154 + await seedDoc('doc'); 155 + const res = await fetch(`${baseUrl}/api/v1/documents?limit=2`); 156 + const body = await res.json() as { data: unknown[]; limit: number }; 157 + expect(body.data.length).toBe(2); 158 + expect(body.limit).toBe(2); 159 + }); 160 + 161 + it('respects offset parameter', async () => { 162 + const res1 = await fetch(`${baseUrl}/api/v1/documents?limit=1&offset=0`); 163 + const body1 = await res1.json() as { data: Array<{ id: string }> }; 164 + const res2 = await fetch(`${baseUrl}/api/v1/documents?limit=1&offset=1`); 165 + const body2 = await res2.json() as { data: Array<{ id: string }> }; 166 + // Different items at different offsets (unless there's only 1 doc) 167 + if (body1.data.length > 0 && body2.data.length > 0) { 168 + expect(body1.data[0].id).not.toBe(body2.data[0].id); 169 + } 170 + }); 171 + 172 + it('clamps limit to max 200', async () => { 173 + const res = await fetch(`${baseUrl}/api/v1/documents?limit=999`); 174 + const body = await res.json() as { limit: number }; 175 + expect(body.limit).toBe(200); 176 + }); 177 + 178 + it('treats limit=0 as default (0 is falsy for parseInt||50)', async () => { 179 + const res = await fetch(`${baseUrl}/api/v1/documents?limit=0`); 180 + const body = await res.json() as { limit: number }; 181 + // parseInt('0') || 50 → 50 because 0 is falsy 182 + expect(body.limit).toBe(50); 183 + }); 184 + 185 + it('clamps limit=1 to minimum 1', async () => { 186 + const res = await fetch(`${baseUrl}/api/v1/documents?limit=1`); 187 + const body = await res.json() as { limit: number }; 188 + expect(body.limit).toBe(1); 189 + }); 190 + 191 + it('clamps negative offset to 0', async () => { 192 + const res = await fetch(`${baseUrl}/api/v1/documents?offset=-5`); 193 + const body = await res.json() as { offset: number }; 194 + expect(body.offset).toBe(0); 195 + }); 196 + 197 + it('handles non-numeric limit/offset gracefully', async () => { 198 + const res = await fetch(`${baseUrl}/api/v1/documents?limit=abc&offset=xyz`); 199 + const body = await res.json() as { limit: number; offset: number }; 200 + expect(body.limit).toBe(50); // default 201 + expect(body.offset).toBe(0); // default 202 + }); 203 + 204 + it('filters by type', async () => { 205 + const sheetId = await seedDoc('sheet', { name: 'filterable' }); 206 + const res = await fetch(`${baseUrl}/api/v1/documents?type=sheet`); 207 + const body = await res.json() as { data: Array<{ id: string; type: string }> }; 208 + expect(body.data.every(d => d.type === 'sheet')).toBe(true); 209 + expect(body.data.some(d => d.id === sheetId)).toBe(true); 210 + }); 211 + 212 + it('ignores invalid type filter and returns all', async () => { 213 + const res = await fetch(`${baseUrl}/api/v1/documents?type=invalid`); 214 + const body = await res.json() as { data: unknown[]; total: number }; 215 + // Should not filter — returns everything 216 + expect(body.total).toBeGreaterThan(0); 217 + }); 218 + 219 + it('excludes soft-deleted documents', async () => { 220 + const deletedId = await seedDoc('doc', { deleted: true }); 221 + const res = await fetch(`${baseUrl}/api/v1/documents`); 222 + const body = await res.json() as { data: Array<{ id: string }> }; 223 + expect(body.data.some(d => d.id === deletedId)).toBe(false); 224 + }); 225 + }); 226 + 227 + describe('GET /api/v1/documents/:id', () => { 228 + it('returns a single document by ID', async () => { 229 + const id = await seedDoc('doc', { name: 'single' }); 230 + const res = await fetch(`${baseUrl}/api/v1/documents/${id}`); 231 + expect(res.status).toBe(200); 232 + const body = await res.json() as { id: string; type: string; name_encrypted: string }; 233 + expect(body.id).toBe(id); 234 + expect(body.type).toBe('doc'); 235 + expect(body.name_encrypted).toBe('single'); 236 + }); 237 + 238 + it('returns 404 for non-existent document', async () => { 239 + const res = await fetch(`${baseUrl}/api/v1/documents/nonexistent-id`); 240 + expect(res.status).toBe(404); 241 + }); 242 + }); 243 + 244 + describe('POST /api/v1/documents/resolve', () => { 245 + it('resolves multiple document IDs', async () => { 246 + const id1 = await seedDoc('doc', { name: 'first' }); 247 + const id2 = await seedDoc('sheet', { name: 'second' }); 248 + const res = await fetch(`${baseUrl}/api/v1/documents/resolve`, { 249 + method: 'POST', 250 + headers: { 'Content-Type': 'application/json' }, 251 + body: JSON.stringify({ ids: [id1, id2] }), 252 + }); 253 + expect(res.status).toBe(200); 254 + const body = await res.json() as { data: Array<{ id: string; type: string }> }; 255 + expect(body.data.length).toBe(2); 256 + expect(body.data.some(d => d.id === id1)).toBe(true); 257 + expect(body.data.some(d => d.id === id2)).toBe(true); 258 + }); 259 + 260 + it('returns empty data for IDs that do not exist', async () => { 261 + const res = await fetch(`${baseUrl}/api/v1/documents/resolve`, { 262 + method: 'POST', 263 + headers: { 'Content-Type': 'application/json' }, 264 + body: JSON.stringify({ ids: ['nonexistent-1', 'nonexistent-2'] }), 265 + }); 266 + expect(res.status).toBe(200); 267 + const body = await res.json() as { data: unknown[] }; 268 + expect(body.data.length).toBe(0); 269 + }); 270 + 271 + it('excludes soft-deleted documents from resolve', async () => { 272 + const id = await seedDoc('doc', { deleted: true, name: 'deleted-resolve' }); 273 + const res = await fetch(`${baseUrl}/api/v1/documents/resolve`, { 274 + method: 'POST', 275 + headers: { 'Content-Type': 'application/json' }, 276 + body: JSON.stringify({ ids: [id] }), 277 + }); 278 + const body = await res.json() as { data: unknown[] }; 279 + expect(body.data.length).toBe(0); 280 + }); 281 + 282 + it('caps batch at 50 IDs', async () => { 283 + // Create 51 IDs, only first 50 should be resolved 284 + const ids: string[] = []; 285 + for (let i = 0; i < 51; i++) { 286 + ids.push(await seedDoc('doc')); 287 + } 288 + const res = await fetch(`${baseUrl}/api/v1/documents/resolve`, { 289 + method: 'POST', 290 + headers: { 'Content-Type': 'application/json' }, 291 + body: JSON.stringify({ ids }), 292 + }); 293 + expect(res.status).toBe(200); 294 + const body = await res.json() as { data: Array<{ id: string }> }; 295 + // Should resolve at most 50 296 + expect(body.data.length).toBeLessThanOrEqual(50); 297 + // The 51st ID should not appear 298 + expect(body.data.some(d => d.id === ids[50])).toBe(false); 299 + }); 300 + 301 + it('rejects missing ids field', async () => { 302 + const res = await fetch(`${baseUrl}/api/v1/documents/resolve`, { 303 + method: 'POST', 304 + headers: { 'Content-Type': 'application/json' }, 305 + body: JSON.stringify({}), 306 + }); 307 + expect(res.status).toBe(400); 308 + }); 309 + 310 + it('rejects empty ids array', async () => { 311 + const res = await fetch(`${baseUrl}/api/v1/documents/resolve`, { 312 + method: 'POST', 313 + headers: { 'Content-Type': 'application/json' }, 314 + body: JSON.stringify({ ids: [] }), 315 + }); 316 + expect(res.status).toBe(400); 317 + }); 318 + 319 + it('rejects ids array with only non-string values', async () => { 320 + const res = await fetch(`${baseUrl}/api/v1/documents/resolve`, { 321 + method: 'POST', 322 + headers: { 'Content-Type': 'application/json' }, 323 + body: JSON.stringify({ ids: [123, null, false] }), 324 + }); 325 + expect(res.status).toBe(400); 326 + }); 327 + 328 + it('rejects ids array with only empty strings', async () => { 329 + const res = await fetch(`${baseUrl}/api/v1/documents/resolve`, { 330 + method: 'POST', 331 + headers: { 'Content-Type': 'application/json' }, 332 + body: JSON.stringify({ ids: ['', ''] }), 333 + }); 334 + expect(res.status).toBe(400); 335 + }); 336 + 337 + it('filters out non-string and empty entries but resolves valid ones', async () => { 338 + const id = await seedDoc('doc'); 339 + const res = await fetch(`${baseUrl}/api/v1/documents/resolve`, { 340 + method: 'POST', 341 + headers: { 'Content-Type': 'application/json' }, 342 + body: JSON.stringify({ ids: [id, '', 123, null] }), 343 + }); 344 + expect(res.status).toBe(200); 345 + const body = await res.json() as { data: Array<{ id: string }> }; 346 + expect(body.data.length).toBe(1); 347 + expect(body.data[0].id).toBe(id); 348 + }); 349 + });