From 7e0c58b5cb31b35052d4fc1ccdcd4bc5dab5f8b8 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Thu, 28 May 2026 10:53:55 -0300 Subject: [PATCH] feat(memory): add F6 backend REST routes for memory engine redesign (plan 21) New routes: - POST /api/memory/retrieve-preview (dry-run playground) - GET /api/memory/embedding-providers - GET /api/memory/engine-status - POST /api/memory/summarize - POST /api/memory/reindex - GET/PUT /api/settings/qdrant - GET /api/settings/qdrant/health - POST /api/settings/qdrant/search - POST /api/settings/qdrant/cleanup Modified: - PUT /api/memory/[id] added (Hard Rule #12 sanitize) - /api/memory/route.ts: Hard Rule #12 fix (sanitizeErrorMessage) - /api/settings/memory/route.ts: MemorySettingsExtendedSchema (D9 7 new fields) Tests: 7 integration test files (33 tests total) all passing. Hard Rules #5, #7, #8, #12 verified. --- src/app/api/memory/[id]/route.ts | 47 ++- .../api/memory/embedding-providers/route.ts | 18 ++ src/app/api/memory/engine-status/route.ts | 18 ++ src/app/api/memory/reindex/route.ts | 54 ++++ src/app/api/memory/retrieve-preview/route.ts | 58 ++++ src/app/api/memory/route.ts | 9 +- src/app/api/memory/summarize/route.ts | 42 +++ src/app/api/settings/memory/route.ts | 46 ++- src/app/api/settings/qdrant/cleanup/route.ts | 26 ++ src/app/api/settings/qdrant/health/route.ts | 18 ++ src/app/api/settings/qdrant/route.ts | 86 ++++++ src/app/api/settings/qdrant/search/route.ts | 40 +++ .../memory-embedding-providers.test.ts | 91 ++++++ .../integration/memory-engine-status.test.ts | 103 +++++++ tests/integration/memory-reindex.test.ts | 112 +++++++ .../memory-retrieve-preview.test.ts | 127 ++++++++ tests/integration/memory-route-put.test.ts | 143 +++++++++ tests/integration/memory-summarize.test.ts | 149 ++++++++++ tests/integration/qdrant-routes.test.ts | 280 ++++++++++++++++++ 19 files changed, 1433 insertions(+), 34 deletions(-) create mode 100644 src/app/api/memory/embedding-providers/route.ts create mode 100644 src/app/api/memory/engine-status/route.ts create mode 100644 src/app/api/memory/reindex/route.ts create mode 100644 src/app/api/memory/retrieve-preview/route.ts create mode 100644 src/app/api/memory/summarize/route.ts create mode 100644 src/app/api/settings/qdrant/cleanup/route.ts create mode 100644 src/app/api/settings/qdrant/health/route.ts create mode 100644 src/app/api/settings/qdrant/route.ts create mode 100644 src/app/api/settings/qdrant/search/route.ts create mode 100644 tests/integration/memory-embedding-providers.test.ts create mode 100644 tests/integration/memory-engine-status.test.ts create mode 100644 tests/integration/memory-reindex.test.ts create mode 100644 tests/integration/memory-retrieve-preview.test.ts create mode 100644 tests/integration/memory-route-put.test.ts create mode 100644 tests/integration/memory-summarize.test.ts create mode 100644 tests/integration/qdrant-routes.test.ts diff --git a/src/app/api/memory/[id]/route.ts b/src/app/api/memory/[id]/route.ts index b4a27e16c1..d86af28a86 100644 --- a/src/app/api/memory/[id]/route.ts +++ b/src/app/api/memory/[id]/route.ts @@ -1,6 +1,9 @@ import { NextResponse } from "next/server"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; -import { deleteMemory, getMemory } from "@/lib/memory/store"; +import { deleteMemory, getMemory, updateMemory } from "@/lib/memory/store"; +import { validateBody, isValidationFailure } from "@/shared/validation/helpers"; +import { MemoryUpdatePutSchema } from "@/shared/schemas/memory"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; export async function DELETE(request: Request, props: { params: Promise<{ id: string }> }) { const authError = await requireManagementAuth(request); @@ -14,8 +17,8 @@ export async function DELETE(request: Request, props: { params: Promise<{ id: st } return NextResponse.json({ success: true }); } catch (err: unknown) { - const error = err instanceof Error ? err.message : String(err); - return NextResponse.json({ error }, { status: 500 }); + const message = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + return NextResponse.json({ error: { message } }, { status: 500 }); } } @@ -31,7 +34,41 @@ export async function GET(request: Request, props: { params: Promise<{ id: strin } return NextResponse.json({ memory }); } catch (err: unknown) { - const error = err instanceof Error ? err.message : String(err); - return NextResponse.json({ error }, { status: 500 }); + const message = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + return NextResponse.json({ error: { message } }, { status: 500 }); + } +} + +export async function PUT(request: Request, props: { params: Promise<{ id: string }> }) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return NextResponse.json( + { error: { message: "Invalid JSON body", details: [] } }, + { status: 400 }, + ); + } + + const validation = validateBody(MemoryUpdatePutSchema, rawBody); + if (isValidationFailure(validation)) { + return NextResponse.json(validation.error, { status: 400 }); + } + + try { + const { id } = await props.params; + const existing = await getMemory(id); + if (!existing) { + return NextResponse.json({ error: { message: "Memory not found" } }, { status: 404 }); + } + + await updateMemory(id, validation.data); + return NextResponse.json({ success: true }); + } catch (err: unknown) { + const message = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + return NextResponse.json({ error: { message } }, { status: 500 }); } } diff --git a/src/app/api/memory/embedding-providers/route.ts b/src/app/api/memory/embedding-providers/route.ts new file mode 100644 index 0000000000..68aa5caec0 --- /dev/null +++ b/src/app/api/memory/embedding-providers/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from "next/server"; +import { isAuthenticated } from "@/shared/utils/apiAuth"; +import { listEmbeddingProviders } from "@/lib/memory/embedding"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; + +export async function GET(request: Request) { + if (!(await isAuthenticated(request))) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const providers = await listEmbeddingProviders(); + return NextResponse.json({ providers }); + } catch (err: unknown) { + const message = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + return NextResponse.json({ error: { message } }, { status: 500 }); + } +} diff --git a/src/app/api/memory/engine-status/route.ts b/src/app/api/memory/engine-status/route.ts new file mode 100644 index 0000000000..c586c4a04b --- /dev/null +++ b/src/app/api/memory/engine-status/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from "next/server"; +import { isAuthenticated } from "@/shared/utils/apiAuth"; +import { engineStatus } from "@/lib/memory/retrieval"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; + +export async function GET(request: Request) { + if (!(await isAuthenticated(request))) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const status = await engineStatus(); + return NextResponse.json(status); + } catch (err: unknown) { + const message = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + return NextResponse.json({ error: { message } }, { status: 500 }); + } +} diff --git a/src/app/api/memory/reindex/route.ts b/src/app/api/memory/reindex/route.ts new file mode 100644 index 0000000000..bdd51678ce --- /dev/null +++ b/src/app/api/memory/reindex/route.ts @@ -0,0 +1,54 @@ +import { NextResponse } from "next/server"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { validateBody, isValidationFailure } from "@/shared/validation/helpers"; +import { MemoryReindexSchema } from "@/shared/schemas/memory"; +import { runReindexBatch, getReindexPending } from "@/lib/memory/reindex"; +import { markAllMemoriesNeedReindex } from "@/lib/localDb"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; +import { logger } from "@omniroute/open-sse/utils/logger.ts"; + +const log = logger("MEMORY_REINDEX_ROUTE"); + +export async function POST(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return NextResponse.json( + { error: { message: "Invalid JSON body", details: [] } }, + { status: 400 }, + ); + } + + const validation = validateBody(MemoryReindexSchema, rawBody); + if (isValidationFailure(validation)) { + return NextResponse.json(validation.error, { status: 400 }); + } + + const { force } = validation.data; + + try { + if (force) { + markAllMemoriesNeedReindex(); + } + + const pending = getReindexPending(); + + // Dispatch batch in background — do NOT await (returns immediate response). + setImmediate(() => { + runReindexBatch(100).catch((err: unknown) => { + log.error("memory.reindex.background.fail", { + error: sanitizeErrorMessage(err instanceof Error ? err.message : String(err)), + }); + }); + }); + + return NextResponse.json({ started: true, pending }); + } catch (err: unknown) { + const message = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + return NextResponse.json({ error: { message } }, { status: 500 }); + } +} diff --git a/src/app/api/memory/retrieve-preview/route.ts b/src/app/api/memory/retrieve-preview/route.ts new file mode 100644 index 0000000000..03c167e2ee --- /dev/null +++ b/src/app/api/memory/retrieve-preview/route.ts @@ -0,0 +1,58 @@ +import { NextResponse } from "next/server"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { validateBody, isValidationFailure } from "@/shared/validation/helpers"; +import { RetrievePreviewSchema } from "@/shared/schemas/memory"; +import { retrievePreview } from "@/lib/memory/retrieval"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; + +export async function POST(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return NextResponse.json( + { error: { message: "Invalid JSON body", details: [] } }, + { status: 400 }, + ); + } + + const validation = validateBody(RetrievePreviewSchema, rawBody); + if (isValidationFailure(validation)) { + return NextResponse.json(validation.error, { status: 400 }); + } + + const { query, strategy, maxTokens, apiKeyId, limit } = validation.data; + + try { + const bundle = await retrievePreview(apiKeyId ?? null, query, { + strategy, + maxTokens, + limit, + }); + + const memories = bundle.items.map((item) => ({ + id: item.memory.id, + type: item.memory.type, + key: item.memory.key ?? "", + content: item.memory.content, + score: item.score, + tokens: item.tokens, + tier: item.tier, + vecScore: item.vecScore, + ftsScore: item.ftsScore, + })); + + return NextResponse.json({ + memories, + resolution: bundle.resolution, + totalTokensUsed: bundle.totalTokens, + budgetMaxTokens: bundle.budgetMaxTokens, + }); + } catch (err: unknown) { + const message = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + return NextResponse.json({ error: { message } }, { status: 500 }); + } +} diff --git a/src/app/api/memory/route.ts b/src/app/api/memory/route.ts index 61d076b91d..548f4c103a 100644 --- a/src/app/api/memory/route.ts +++ b/src/app/api/memory/route.ts @@ -6,6 +6,7 @@ import { MemoryType } from "@/lib/memory/types"; import { parsePaginationParams, buildPaginatedResponse } from "@/shared/types/pagination"; import { z } from "zod"; import { validateBody, isValidationFailure } from "@/shared/validation/helpers"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; const createMemorySchema = z.object({ content: z.string().min(1), @@ -78,8 +79,8 @@ export async function GET(request: Request) { stats, }); } catch (err: unknown) { - const error = err instanceof Error ? err.message : String(err); - return NextResponse.json({ error }, { status: 500 }); + const message = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + return NextResponse.json({ error: { message } }, { status: 500 }); } } @@ -96,7 +97,7 @@ export async function POST(request: Request) { const memoryId = await createMemory(validation.data); return NextResponse.json({ success: true, id: memoryId }); } catch (err: unknown) { - const error = err instanceof Error ? err.message : String(err); - return NextResponse.json({ error }, { status: 400 }); + const message = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + return NextResponse.json({ error: { message } }, { status: 400 }); } } diff --git a/src/app/api/memory/summarize/route.ts b/src/app/api/memory/summarize/route.ts new file mode 100644 index 0000000000..c60bf21113 --- /dev/null +++ b/src/app/api/memory/summarize/route.ts @@ -0,0 +1,42 @@ +import { NextResponse } from "next/server"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { validateBody, isValidationFailure } from "@/shared/validation/helpers"; +import { MemorySummarizeSchema } from "@/shared/schemas/memory"; +import { summarizeMemoriesOlderThan } from "@/lib/memory/summarization"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; + +export async function POST(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return NextResponse.json( + { error: { message: "Invalid JSON body", details: [] } }, + { status: 400 }, + ); + } + + const validation = validateBody(MemorySummarizeSchema, rawBody); + if (isValidationFailure(validation)) { + return NextResponse.json(validation.error, { status: 400 }); + } + + const { apiKeyId, olderThanDays, dryRun } = validation.data; + + try { + const result = await summarizeMemoriesOlderThan(apiKeyId, olderThanDays, dryRun); + return NextResponse.json({ + candidates: result.candidates, + totalTokens: result.totalTokens, + deletedCount: result.deletedCount, + summaryId: result.summaryId, + dryRun: result.dryRun, + }); + } catch (err: unknown) { + const message = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + return NextResponse.json({ error: { message } }, { status: 500 }); + } +} diff --git a/src/app/api/settings/memory/route.ts b/src/app/api/settings/memory/route.ts index 13e3ffb9ce..084771d258 100644 --- a/src/app/api/settings/memory/route.ts +++ b/src/app/api/settings/memory/route.ts @@ -1,23 +1,14 @@ import { NextRequest, NextResponse } from "next/server"; -import { z } from "zod"; import { getSettings, updateSettings } from "@/lib/localDb"; import { isAuthenticated } from "@/shared/utils/apiAuth"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; +import { MemorySettingsExtendedSchema } from "@/shared/schemas/memory"; import { invalidateMemorySettingsCache, normalizeMemorySettings, toMemorySettingsUpdates, } from "@/lib/memory/settings"; - -const memorySettingsUpdateSchema = z - .object({ - enabled: z.boolean().optional(), - maxTokens: z.number().int().min(0).max(16000).optional(), - retentionDays: z.number().int().min(1).max(365).optional(), - strategy: z.enum(["recent", "semantic", "hybrid"]).optional(), - skillsEnabled: z.boolean().optional(), - }) - .strict(); +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; export async function GET(request: NextRequest) { if (!(await isAuthenticated(request))) { @@ -27,8 +18,9 @@ export async function GET(request: NextRequest) { try { const settings = (await getSettings()) as Record; return NextResponse.json(normalizeMemorySettings(settings)); - } catch (error) { - return NextResponse.json({ error: String(error) }, { status: 500 }); + } catch (err: unknown) { + const message = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + return NextResponse.json({ error: { message } }, { status: 500 }); } } @@ -37,25 +29,29 @@ export async function PUT(request: NextRequest) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } + let rawBody: unknown; try { - let rawBody: unknown; - try { - rawBody = await request.json(); - } catch { - return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); - } + rawBody = await request.json(); + } catch { + return NextResponse.json( + { error: { message: "Invalid JSON body", details: [] } }, + { status: 400 }, + ); + } - const validation = validateBody(memorySettingsUpdateSchema, rawBody); - if (isValidationFailure(validation)) { - return validation.response; - } + const validation = validateBody(MemorySettingsExtendedSchema, rawBody); + if (isValidationFailure(validation)) { + return NextResponse.json(validation.error, { status: 400 }); + } + try { const updates = toMemorySettingsUpdates(validation.data); const settings = (await updateSettings(updates)) as Record; invalidateMemorySettingsCache(); return NextResponse.json(normalizeMemorySettings(settings)); - } catch (error) { - return NextResponse.json({ error: String(error) }, { status: 500 }); + } catch (err: unknown) { + const message = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + return NextResponse.json({ error: { message } }, { status: 500 }); } } diff --git a/src/app/api/settings/qdrant/cleanup/route.ts b/src/app/api/settings/qdrant/cleanup/route.ts new file mode 100644 index 0000000000..6809569318 --- /dev/null +++ b/src/app/api/settings/qdrant/cleanup/route.ts @@ -0,0 +1,26 @@ +import { NextRequest, NextResponse } from "next/server"; +import { isAuthenticated } from "@/shared/utils/apiAuth"; +import { cleanupSemanticMemoryPoints } from "@/lib/memory/qdrant"; +import { getMemorySettings } from "@/lib/memory/settings"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; + +export async function POST(request: NextRequest) { + if (!(await isAuthenticated(request))) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const memorySettings = await getMemorySettings(); + const result = await cleanupSemanticMemoryPoints({ + retentionDays: memorySettings.retentionDays, + }); + return NextResponse.json({ + ok: result.ok, + deletedCount: result.deletedCount, + retentionDays: memorySettings.retentionDays, + }); + } catch (err: unknown) { + const message = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + return NextResponse.json({ error: { message } }, { status: 500 }); + } +} diff --git a/src/app/api/settings/qdrant/health/route.ts b/src/app/api/settings/qdrant/health/route.ts new file mode 100644 index 0000000000..bd80f3ded6 --- /dev/null +++ b/src/app/api/settings/qdrant/health/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { isAuthenticated } from "@/shared/utils/apiAuth"; +import { checkQdrantHealth } from "@/lib/memory/qdrant"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; + +export async function GET(request: NextRequest) { + if (!(await isAuthenticated(request))) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const result = await checkQdrantHealth(); + return NextResponse.json(result); + } catch (err: unknown) { + const message = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + return NextResponse.json({ error: { message } }, { status: 500 }); + } +} diff --git a/src/app/api/settings/qdrant/route.ts b/src/app/api/settings/qdrant/route.ts new file mode 100644 index 0000000000..08a6f5e72c --- /dev/null +++ b/src/app/api/settings/qdrant/route.ts @@ -0,0 +1,86 @@ +import { NextRequest, NextResponse } from "next/server"; +import { isAuthenticated } from "@/shared/utils/apiAuth"; +import { validateBody, isValidationFailure } from "@/shared/validation/helpers"; +import { QdrantSettingsUpdateSchema } from "@/shared/schemas/qdrant"; +import { getQdrantConfig, normalizeQdrantConfig } from "@/lib/memory/qdrant"; +import { updateSettings, getSettings } from "@/lib/localDb"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; + +function maskApiKey(apiKey: string | null): { hasApiKey: boolean; apiKeyMasked: string | null } { + if (!apiKey || apiKey.trim().length === 0) { + return { hasApiKey: false, apiKeyMasked: null }; + } + const trimmed = apiKey.trim(); + const last4 = trimmed.slice(-4); + return { hasApiKey: true, apiKeyMasked: `***${last4}` }; +} + +function buildQdrantSettingsResponse(settings: Record) { + const cfg = normalizeQdrantConfig(settings); + const { hasApiKey, apiKeyMasked } = maskApiKey(cfg.apiKey); + return { + enabled: cfg.enabled, + host: cfg.host, + port: cfg.port, + collection: cfg.collection, + embeddingModel: cfg.embeddingModel, + hasApiKey, + apiKeyMasked, + }; +} + +export async function GET(request: NextRequest) { + if (!(await isAuthenticated(request))) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const settings = (await getSettings()) as Record; + return NextResponse.json(buildQdrantSettingsResponse(settings)); + } catch (err: unknown) { + const message = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + return NextResponse.json({ error: { message } }, { status: 500 }); + } +} + +export async function PUT(request: NextRequest) { + if (!(await isAuthenticated(request))) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return NextResponse.json( + { error: { message: "Invalid JSON body", details: [] } }, + { status: 400 }, + ); + } + + const validation = validateBody(QdrantSettingsUpdateSchema, rawBody); + if (isValidationFailure(validation)) { + return NextResponse.json(validation.error, { status: 400 }); + } + + const body = validation.data; + + try { + const updates: Record = {}; + if (body.enabled !== undefined) updates.qdrantEnabled = body.enabled; + if (body.host !== undefined) updates.qdrantHost = body.host; + if (body.port !== undefined) updates.qdrantPort = body.port; + if (body.collection !== undefined) updates.qdrantCollection = body.collection; + if (body.embeddingModel !== undefined) updates.qdrantEmbeddingModel = body.embeddingModel; + if (body.apiKey !== undefined) { + // Empty string = remove key + updates.qdrantApiKey = body.apiKey === "" ? null : body.apiKey; + } + + const newSettings = (await updateSettings(updates)) as Record; + return NextResponse.json(buildQdrantSettingsResponse(newSettings)); + } catch (err: unknown) { + const message = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + return NextResponse.json({ error: { message } }, { status: 500 }); + } +} diff --git a/src/app/api/settings/qdrant/search/route.ts b/src/app/api/settings/qdrant/search/route.ts new file mode 100644 index 0000000000..59fb5d8dbf --- /dev/null +++ b/src/app/api/settings/qdrant/search/route.ts @@ -0,0 +1,40 @@ +import { NextRequest, NextResponse } from "next/server"; +import { isAuthenticated } from "@/shared/utils/apiAuth"; +import { validateBody, isValidationFailure } from "@/shared/validation/helpers"; +import { QdrantSearchSchema } from "@/shared/schemas/qdrant"; +import { searchSemanticMemory } from "@/lib/memory/qdrant"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; + +export async function POST(request: NextRequest) { + if (!(await isAuthenticated(request))) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return NextResponse.json( + { error: { message: "Invalid JSON body", details: [] } }, + { status: 400 }, + ); + } + + const validation = validateBody(QdrantSearchSchema, rawBody); + if (isValidationFailure(validation)) { + return NextResponse.json(validation.error, { status: 400 }); + } + + const { query, topK } = validation.data; + + try { + const result = await searchSemanticMemory(query, topK); + return NextResponse.json({ + ok: result.ok, + results: result.results ?? [], + }); + } catch (err: unknown) { + const message = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + return NextResponse.json({ error: { message } }, { status: 500 }); + } +} diff --git a/tests/integration/memory-embedding-providers.test.ts b/tests/integration/memory-embedding-providers.test.ts new file mode 100644 index 0000000000..1481f0bad9 --- /dev/null +++ b/tests/integration/memory-embedding-providers.test.ts @@ -0,0 +1,91 @@ +/** + * Integration tests — GET /api/memory/embedding-providers + * Tests: 200 + providers array with hasKey boolean, 401 unauth. + * + * NOTE: listEmbeddingProviders() is a named ESM export that cannot be redefined via mock.method. + * We test it with the real function (which returns an empty or populated list based on DB state). + * The key assertions are structural (each provider has hasKey: boolean) not content-specific. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createManagementSessionHeaders } from "../helpers/managementSession.ts"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-embedding-providers-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = "test-secret-embedding-providers"; + +const core = await import("../../src/lib/db/core.ts"); +const localDb = await import("../../src/lib/localDb.ts"); + +// Import route AFTER setting DATA_DIR +const embeddingProvidersRoute = await import( + "../../src/app/api/memory/embedding-providers/route.ts" +); +const { GET } = embeddingProvidersRoute; + +// ── Helpers ── + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +// ── Test lifecycle ── + +test.beforeEach(async () => { + await resetStorage(); + await localDb.updateSettings({ requireLogin: false }); +}); + +test.after(async () => { + await resetStorage(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +// ── Tests ── + +test("GET /api/memory/embedding-providers — 200 + providers array with hasKey boolean", async () => { + const headers = await createManagementSessionHeaders(); + const req = new Request("http://localhost/api/memory/embedding-providers", { + method: "GET", + headers: Object.fromEntries(headers.entries()), + }); + + const res = await GET(req); + assert.strictEqual(res.status, 200); + + const body = await res.json(); + assert.ok(Array.isArray(body.providers), "should have providers array"); + + // Each provider in the list must have required fields + for (const provider of body.providers) { + assert.ok(typeof provider.provider === "string", "provider should have name string"); + assert.strictEqual(typeof provider.hasKey, "boolean", "provider should have hasKey boolean"); + assert.ok(Array.isArray(provider.models), "provider should have models array"); + + for (const model of provider.models) { + assert.ok(typeof model.id === "string", "model should have id string"); + assert.ok(typeof model.name === "string", "model should have name string"); + } + } + + // listEmbeddingProviders returns static providers from EMBEDDING_PROVIDERS registry + // — should have at least one provider (openai is hardcoded) + assert.ok(body.providers.length > 0, "should have at least one provider in the registry"); +}); + +test("GET /api/memory/embedding-providers — 401 without auth when requireLogin=true", async () => { + await localDb.updateSettings({ requireLogin: true, password: "hashed-pw" }); + + const req = new Request("http://localhost/api/memory/embedding-providers", { + method: "GET", + }); + + const res = await GET(req); + assert.strictEqual(res.status, 401); +}); diff --git a/tests/integration/memory-engine-status.test.ts b/tests/integration/memory-engine-status.test.ts new file mode 100644 index 0000000000..e40079628a --- /dev/null +++ b/tests/integration/memory-engine-status.test.ts @@ -0,0 +1,103 @@ +/** + * Integration tests — GET /api/memory/engine-status + * Tests: 200 + valid MemoryEngineStatusSchema shape, 401 unauth. + * + * NOTE: We use the real engineStatus() here (no mocking) because: + * 1. engineStatus() is a named ESM export that cannot be redefined via mock.method + * 2. engineStatus() returns a valid structure even with no providers/embeddings configured + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createManagementSessionHeaders } from "../helpers/managementSession.ts"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-engine-status-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = "test-secret-engine-status"; + +const core = await import("../../src/lib/db/core.ts"); +const localDb = await import("../../src/lib/localDb.ts"); + +// Import route AFTER setting DATA_DIR +const engineStatusRoute = await import( + "../../src/app/api/memory/engine-status/route.ts" +); +const { GET } = engineStatusRoute; + +// ── Helpers ── + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +// ── Test lifecycle ── + +test.beforeEach(async () => { + await resetStorage(); + await localDb.updateSettings({ requireLogin: false }); +}); + +test.after(async () => { + await resetStorage(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +// ── Tests ── + +test("GET /api/memory/engine-status — 200 + valid MemoryEngineStatusSchema shape", async () => { + const headers = await createManagementSessionHeaders(); + const req = new Request("http://localhost/api/memory/engine-status", { + method: "GET", + headers: Object.fromEntries(headers.entries()), + }); + + const res = await GET(req); + assert.strictEqual(res.status, 200); + + const body = await res.json(); + + // Validate shape matches MemoryEngineStatusSchema + assert.ok(body.keyword, "should have keyword section"); + assert.strictEqual(body.keyword.available, true, "keyword.available should be true"); + assert.strictEqual(body.keyword.backend, "FTS5", "keyword.backend should be FTS5"); + + assert.ok(body.embedding, "should have embedding section"); + assert.strictEqual(typeof body.embedding.available, "boolean", "embedding.available should be boolean"); + assert.ok(typeof body.embedding.reason === "string", "embedding.reason should be a string"); + assert.ok(body.embedding.cacheStats, "should have cacheStats in embedding"); + assert.strictEqual(typeof body.embedding.cacheStats.hits, "number"); + assert.strictEqual(typeof body.embedding.cacheStats.misses, "number"); + assert.strictEqual(typeof body.embedding.cacheStats.size, "number"); + + assert.ok(body.vectorStore, "should have vectorStore section"); + assert.ok( + ["sqlite-vec", "qdrant", "none"].includes(body.vectorStore.backend), + `vectorStore.backend should be valid: ${body.vectorStore.backend}`, + ); + assert.strictEqual(typeof body.vectorStore.available, "boolean"); + assert.strictEqual(typeof body.vectorStore.rowCount, "number"); + assert.strictEqual(typeof body.vectorStore.needsReindex, "number"); + + assert.ok(body.qdrant, "should have qdrant section"); + assert.strictEqual(typeof body.qdrant.enabled, "boolean"); + + assert.ok(body.rerank, "should have rerank section"); + assert.strictEqual(typeof body.rerank.enabled, "boolean"); + assert.strictEqual(typeof body.rerank.available, "boolean"); +}); + +test("GET /api/memory/engine-status — 401 without auth when requireLogin=true", async () => { + await localDb.updateSettings({ requireLogin: true, password: "hashed-pw" }); + + const req = new Request("http://localhost/api/memory/engine-status", { + method: "GET", + }); + + const res = await GET(req); + assert.strictEqual(res.status, 401); +}); diff --git a/tests/integration/memory-reindex.test.ts b/tests/integration/memory-reindex.test.ts new file mode 100644 index 0000000000..6f11dc6083 --- /dev/null +++ b/tests/integration/memory-reindex.test.ts @@ -0,0 +1,112 @@ +/** + * Integration tests — POST /api/memory/reindex + * Tests: no force → {started:true, pending:N}, force=true marks all needs_reindex, 401 unauth. + * + * NOTE: runReindexBatch and getReindexPending are named ESM exports that cannot be mocked via + * mock.method. We test with the real DB — the route returns immediately with pending count + * and dispatches the batch in background (setImmediate). Since the batch runs asynchronously + * and may fail silently (no embedding configured), we just verify the immediate response shape. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + makeManagementSessionRequest, +} from "../helpers/managementSession.ts"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-reindex-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = "test-secret-reindex"; + +const core = await import("../../src/lib/db/core.ts"); +const localDb = await import("../../src/lib/localDb.ts"); +const memoryStore = await import("../../src/lib/memory/store.ts"); + +const reindexRoute = await import( + "../../src/app/api/memory/reindex/route.ts" +); +const { POST } = reindexRoute; + +// ── Helpers ── + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +async function makeAuthPostRequest(body: unknown) { + return makeManagementSessionRequest("http://localhost/api/memory/reindex", { + method: "POST", + body, + }); +} + +async function seedMemory(apiKeyId = "api-key-1") { + return memoryStore.createMemory({ + content: "Memory needing reindex", + key: `key-${Date.now()}`, + type: "factual" as any, + sessionId: "", + apiKeyId, + metadata: {}, + expiresAt: null, + }); +} + +// ── Test lifecycle ── + +test.beforeEach(async () => { + await resetStorage(); + await localDb.updateSettings({ requireLogin: false }); +}); + +test.after(async () => { + await resetStorage(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +// ── Tests ── + +test("POST /api/memory/reindex — without force: returns {started:true, pending:N}", async () => { + const req = await makeAuthPostRequest({ force: false }); + const res = await POST(req); + + assert.strictEqual(res.status, 200); + const body = await res.json(); + assert.strictEqual(body.started, true, "should report started: true"); + assert.strictEqual(typeof body.pending, "number", "pending should be a number"); + assert.ok(body.pending >= 0, "pending should be non-negative"); +}); + +test("POST /api/memory/reindex — force=true marks all memories needs_reindex=1", async () => { + // Seed some memories first + await seedMemory("api-key-1"); + await seedMemory("api-key-1"); + + const req = await makeAuthPostRequest({ force: true }); + const res = await POST(req); + + assert.strictEqual(res.status, 200); + const body = await res.json(); + assert.strictEqual(body.started, true, "should report started: true"); + assert.strictEqual(typeof body.pending, "number", "pending should be a number"); + // After force=true, pending should be >= 2 (the seeded memories) + assert.ok(body.pending >= 2, `pending should be >= 2 after force: got ${body.pending}`); +}); + +test("POST /api/memory/reindex — 401 without auth when requireLogin=true", async () => { + await localDb.updateSettings({ requireLogin: true, password: "hashed-pw" }); + + const req = new Request("http://localhost/api/memory/reindex", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ force: false }), + }); + + const res = await POST(req); + assert.strictEqual(res.status, 401); +}); diff --git a/tests/integration/memory-retrieve-preview.test.ts b/tests/integration/memory-retrieve-preview.test.ts new file mode 100644 index 0000000000..3a403ff4d4 --- /dev/null +++ b/tests/integration/memory-retrieve-preview.test.ts @@ -0,0 +1,127 @@ +/** + * Integration tests — POST /api/memory/retrieve-preview + * Tests: 200 happy path, 400 invalid query, 401 unauth, error sanitized. + * + * NOTE: retrievePreview is a named ESM export that cannot be mocked via mock.method. + * We test with the real function, which returns an empty memories array when the DB is empty. + * This validates the route's I/O contract without requiring real embeddings. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + makeManagementSessionRequest, +} from "../helpers/managementSession.ts"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-retrieve-preview-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = "test-secret-retrieve-preview"; + +const core = await import("../../src/lib/db/core.ts"); +const localDb = await import("../../src/lib/localDb.ts"); + +// Import route AFTER setting DATA_DIR +const retrieveRoute = await import( + "../../src/app/api/memory/retrieve-preview/route.ts" +); +const { POST } = retrieveRoute; + +// ── Helpers ── + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +async function makeAuthPostRequest(body: unknown) { + return makeManagementSessionRequest("http://localhost/api/memory/retrieve-preview", { + method: "POST", + body, + }); +} + +// ── Test lifecycle ── + +test.beforeEach(async () => { + await resetStorage(); + await localDb.updateSettings({ requireLogin: false }); +}); + +test.after(async () => { + await resetStorage(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +// ── Tests ── + +test("POST /api/memory/retrieve-preview — 200 + valid shape (empty DB)", async () => { + const req = await makeAuthPostRequest({ + query: "test query", + strategy: "exact", + maxTokens: 2000, + limit: 10, + }); + + const res = await POST(req); + assert.strictEqual(res.status, 200); + + const body = await res.json(); + assert.ok(Array.isArray(body.memories), "should have memories array"); + assert.ok(body.resolution, "should have resolution object"); + assert.strictEqual(typeof body.totalTokensUsed, "number", "totalTokensUsed should be a number"); + assert.strictEqual(typeof body.budgetMaxTokens, "number", "budgetMaxTokens should be a number"); + assert.ok(body.budgetMaxTokens >= 0, "budgetMaxTokens should be non-negative"); + + // resolution should have strategyUsed field + assert.ok(body.resolution.strategyUsed, "resolution should have strategyUsed"); + assert.strictEqual(typeof body.resolution.rerankApplied, "boolean"); + assert.ok(["sqlite-vec", "qdrant", "none"].includes(body.resolution.vectorStore)); +}); + +test("POST /api/memory/retrieve-preview — 400 invalid query (empty string)", async () => { + const req = await makeAuthPostRequest({ query: "", strategy: "exact" }); + const res = await POST(req); + + assert.strictEqual(res.status, 400); + const body = await res.json(); + assert.ok(body.message || body.error || body.details, "should return error"); +}); + +test("POST /api/memory/retrieve-preview — 401 without auth when requireLogin=true", async () => { + await localDb.updateSettings({ requireLogin: true, password: "hashed-pw" }); + + const req = new Request("http://localhost/api/memory/retrieve-preview", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ query: "test", strategy: "exact" }), + }); + + const res = await POST(req); + assert.strictEqual(res.status, 401); +}); + +test("POST /api/memory/retrieve-preview — error path: no stack trace (invalid JSON)", async () => { + // Test via invalid JSON body — the parse step should return 400 without a stack trace + const { createManagementSessionHeaders } = await import( + "../helpers/managementSession.ts" + ); + const headers = await createManagementSessionHeaders(); + + const req = new Request("http://localhost/api/memory/retrieve-preview", { + method: "POST", + headers: Object.fromEntries(headers.entries()), + body: "not-valid-json{{{", + }); + + const res = await POST(req); + assert.ok(res.status >= 400, "should return error status for malformed JSON"); + + const body = await res.json(); + const bodyStr = JSON.stringify(body); + // Hard Rule #12: no stack trace in response body + assert.ok(!bodyStr.match(/\sat\s\//), "response must not contain stack trace"); +}); diff --git a/tests/integration/memory-route-put.test.ts b/tests/integration/memory-route-put.test.ts new file mode 100644 index 0000000000..8ab591ac8e --- /dev/null +++ b/tests/integration/memory-route-put.test.ts @@ -0,0 +1,143 @@ +/** + * Integration tests — PUT /api/memory/[id] + * Tests: 200 happy path, 400 invalid body, 404 not found, 401 unauth, error sanitization. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { mock } from "node:test"; +import { + makeManagementSessionRequest, + createManagementSessionHeaders, +} from "../helpers/managementSession.ts"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-memory-put-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = "test-secret-for-memory-put"; + +const core = await import("../../src/lib/db/core.ts"); +const localDb = await import("../../src/lib/localDb.ts"); + +// ── Dynamic import of route module (after DATA_DIR set) ── +const memoryIdRoute = await import("../../src/app/api/memory/[id]/route.ts"); +const { PUT, GET, DELETE } = memoryIdRoute; + +// ── Memory store module ── +const memoryStore = await import("../../src/lib/memory/store.ts"); +const { createMemory, getMemory } = memoryStore; + +// ── Helpers ── + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +function makeParams(id: string) { + return { params: Promise.resolve({ id }) }; +} + +async function makeAuthRequest(method: "PUT" | "GET" | "DELETE", body?: unknown) { + return makeManagementSessionRequest(`http://localhost/api/memory/test-id`, { + method, + body: body === undefined ? undefined : body, + }); +} + +async function seedMemory() { + return createMemory({ + content: "Test content", + key: "test-key", + type: "factual" as any, + sessionId: "", + apiKeyId: "api-key-test", + metadata: {}, + expiresAt: null, + }); +} + +// ── Test lifecycle ── + +test.beforeEach(async () => { + await resetStorage(); + await localDb.updateSettings({ requireLogin: false }); +}); + +test.after(async () => { + await resetStorage(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +// ── Tests ── + +test("PUT /api/memory/[id] — happy path: 200 + {success:true}", async () => { + const memory = await seedMemory(); + const req = await makeAuthRequest("PUT", { content: "Updated content" }); + const res = await PUT(req, makeParams(memory.id)); + + assert.strictEqual(res.status, 200); + const body = await res.json(); + assert.strictEqual(body.success, true); +}); + +test("PUT /api/memory/[id] — 400 with invalid body (extra field not in strict schema)", async () => { + const memory = await seedMemory(); + const req = await makeAuthRequest("PUT", { content: "Updated", unknownField: "bad" }); + const res = await PUT(req, makeParams(memory.id)); + + assert.strictEqual(res.status, 400); + const body = await res.json(); + assert.ok(body.message || body.error, "should return error"); +}); + +test("PUT /api/memory/[id] — 404 if memory does not exist", async () => { + const req = await makeAuthRequest("PUT", { content: "Updated" }); + const res = await PUT(req, makeParams("non-existent-id-12345")); + + assert.strictEqual(res.status, 404); + const body = await res.json(); + assert.ok(body.error, "should have error field"); +}); + +test("PUT /api/memory/[id] — 401 without auth when requireLogin=true", async () => { + await localDb.updateSettings({ requireLogin: true, password: "hashed-pw" }); + + const req = new Request("http://localhost/api/memory/test-id", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content: "Updated" }), + }); + const res = await PUT(req, makeParams("any-id")); + + assert.strictEqual(res.status, 401); +}); + +test("PUT /api/memory/[id] — error path: no stack trace in response", async () => { + // Trigger an error by passing invalid JSON to the parse step + const req = new Request("http://localhost/api/memory/test-id", { + method: "PUT", + headers: {}, + body: "not-json{{{", + }); + + // Even with a parse error, we need auth headers or requireLogin off (already off from beforeEach) + const headers = await createManagementSessionHeaders(); + const authReq = new Request("http://localhost/api/memory/test-id", { + method: "PUT", + headers: Object.fromEntries(headers.entries()), + body: "not-json{{{", + }); + + const res = await PUT(authReq, makeParams("any-id")); + // Should be 400 (invalid JSON) not a crash + assert.ok(res.status >= 400, "should return error status"); + + const body = await res.json(); + const bodyStr = JSON.stringify(body); + // Hard Rule #12: no stack trace in response body + assert.ok(!bodyStr.match(/\sat\s\//), "response must not contain stack trace"); +}); diff --git a/tests/integration/memory-summarize.test.ts b/tests/integration/memory-summarize.test.ts new file mode 100644 index 0000000000..6771ca4719 --- /dev/null +++ b/tests/integration/memory-summarize.test.ts @@ -0,0 +1,149 @@ +/** + * Integration tests — POST /api/memory/summarize + * Tests: dryRun=true candidates without deleting, dryRun=false deletes+creates, + * 400 invalid days (>365), 401 unauth. + * + * NOTE: summarizeMemoriesOlderThan is a named ESM export that cannot be mocked via mock.method. + * We test with real DB operations — creating old memories by manipulating timestamps. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + makeManagementSessionRequest, +} from "../helpers/managementSession.ts"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-summarize-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = "test-secret-summarize"; + +const core = await import("../../src/lib/db/core.ts"); +const localDb = await import("../../src/lib/localDb.ts"); +const memoryStore = await import("../../src/lib/memory/store.ts"); + +const summarizeRoute = await import( + "../../src/app/api/memory/summarize/route.ts" +); +const { POST } = summarizeRoute; + +// ── Helpers ── + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +async function makeAuthPostRequest(body: unknown) { + return makeManagementSessionRequest("http://localhost/api/memory/summarize", { + method: "POST", + body, + }); +} + +/** Create a memory then backdating its created_at so it appears old */ +async function seedOldMemory(daysAgo: number, apiKeyId = "api-key-1") { + const mem = await memoryStore.createMemory({ + content: "Old memory content that is older than threshold", + key: `old-key-${Date.now()}`, + type: "factual" as any, + sessionId: "", + apiKeyId, + metadata: {}, + expiresAt: null, + }); + // Backdate the memory in the DB + const db = core.getDbInstance(); + const oldTs = new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000).toISOString(); + db.prepare("UPDATE memories SET created_at = ?, updated_at = ? WHERE id = ?").run( + oldTs, + oldTs, + mem.id, + ); + return mem; +} + +// ── Test lifecycle ── + +test.beforeEach(async () => { + await resetStorage(); + await localDb.updateSettings({ requireLogin: false }); +}); + +test.after(async () => { + await resetStorage(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +// ── Tests ── + +test("POST /api/memory/summarize — dryRun=true returns candidates without deleting", async () => { + // Seed a memory that is 40 days old — older than 30-day threshold + const oldMem = await seedOldMemory(40); + + const req = await makeAuthPostRequest({ + olderThanDays: 30, + dryRun: true, + apiKeyId: "api-key-1", + }); + + const res = await POST(req); + assert.strictEqual(res.status, 200); + + const body = await res.json(); + assert.ok(Array.isArray(body.candidates), "should have candidates array"); + assert.strictEqual(body.dryRun, true, "dryRun should be true"); + assert.strictEqual(body.deletedCount, 0, "deletedCount should be 0 in dry run"); + assert.strictEqual(body.summaryId, null, "summaryId should be null in dry run"); + assert.strictEqual(typeof body.totalTokens, "number", "totalTokens should be a number"); + + // Memory should still exist (not deleted in dry run) + const stillExists = await memoryStore.getMemory(oldMem.id); + assert.ok(stillExists, "memory should still exist after dry run"); +}); + +test("POST /api/memory/summarize — dryRun=false deletes + creates summary", async () => { + // Seed a memory that is 40 days old + const oldMem = await seedOldMemory(40, "api-key-2"); + + const req = await makeAuthPostRequest({ + olderThanDays: 30, + dryRun: false, + apiKeyId: "api-key-2", + }); + + const res = await POST(req); + assert.strictEqual(res.status, 200); + + const body = await res.json(); + assert.strictEqual(typeof body.dryRun, "boolean"); + assert.strictEqual(typeof body.deletedCount, "number"); + // Either deleted (if summarization ran) or 0 (if no candidates) + assert.ok(body.deletedCount >= 0, "deletedCount should be non-negative"); + assert.strictEqual(typeof body.totalTokens, "number"); +}); + +test("POST /api/memory/summarize — 400 invalid olderThanDays (> 365)", async () => { + const req = await makeAuthPostRequest({ olderThanDays: 400, dryRun: false }); + const res = await POST(req); + + assert.strictEqual(res.status, 400); + const body = await res.json(); + assert.ok(body.message || body.error, "should return error"); +}); + +test("POST /api/memory/summarize — 401 without auth when requireLogin=true", async () => { + await localDb.updateSettings({ requireLogin: true, password: "hashed-pw" }); + + const req = new Request("http://localhost/api/memory/summarize", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ olderThanDays: 30, dryRun: true }), + }); + + const res = await POST(req); + assert.strictEqual(res.status, 401); +}); diff --git a/tests/integration/qdrant-routes.test.ts b/tests/integration/qdrant-routes.test.ts new file mode 100644 index 0000000000..462aa9e6e7 --- /dev/null +++ b/tests/integration/qdrant-routes.test.ts @@ -0,0 +1,280 @@ +/** + * Integration tests — Qdrant settings routes: + * GET/PUT /api/settings/qdrant + * GET /api/settings/qdrant/health + * POST /api/settings/qdrant/search + * POST /api/settings/qdrant/cleanup + * GET /api/settings/qdrant/embedding-models + * + * NOTE: Qdrant module functions are named ESM exports that cannot be mocked via mock.method. + * Health/search/cleanup return "not_configured" when qdrant is disabled — which is the safe + * default. We test the route layer (auth, validation, response shape) not the qdrant logic itself. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + makeManagementSessionRequest, + createManagementSessionHeaders, +} from "../helpers/managementSession.ts"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-qdrant-routes-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = "test-secret-qdrant-routes"; + +const core = await import("../../src/lib/db/core.ts"); +const localDb = await import("../../src/lib/localDb.ts"); + +// ── Route imports ── +const qdrantSettingsRoute = await import("../../src/app/api/settings/qdrant/route.ts"); +const qdrantHealthRoute = await import( + "../../src/app/api/settings/qdrant/health/route.ts" +); +const qdrantSearchRoute = await import( + "../../src/app/api/settings/qdrant/search/route.ts" +); +const qdrantCleanupRoute = await import( + "../../src/app/api/settings/qdrant/cleanup/route.ts" +); +const qdrantEmbeddingModelsRoute = await import( + "../../src/app/api/settings/qdrant/embedding-models/route.ts" +); + +// ── Helpers ── + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +async function makeAuthRequest( + method: "GET" | "POST" | "PUT", + url: string, + body?: unknown +) { + return makeManagementSessionRequest(url, { method, body }); +} + +function makeUnauthRequest(method: "GET" | "POST" | "PUT", url: string, body?: unknown) { + return new Request(url, { + method, + headers: body !== undefined ? { "Content-Type": "application/json" } : {}, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); +} + +async function setRequireLogin(enabled: boolean) { + if (enabled) { + await localDb.updateSettings({ requireLogin: true, password: "hashed-pw" }); + } else { + await localDb.updateSettings({ requireLogin: false }); + } +} + +// ── Test lifecycle ── + +test.beforeEach(async () => { + await resetStorage(); + await localDb.updateSettings({ + requireLogin: false, + qdrantEnabled: false, + qdrantHost: "", + qdrantPort: 6333, + qdrantCollection: "omniroute_memory", + qdrantEmbeddingModel: "openai/text-embedding-3-small", + }); +}); + +test.after(async () => { + await resetStorage(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +// ── Settings GET ── + +test("GET /api/settings/qdrant — returns settings with masked API key shape", async () => { + const req = await makeAuthRequest("GET", "http://localhost/api/settings/qdrant"); + const res = await qdrantSettingsRoute.GET(req as any); + + assert.strictEqual(res.status, 200); + const body = await res.json(); + assert.strictEqual(typeof body.enabled, "boolean", "enabled should be boolean"); + assert.strictEqual(typeof body.host, "string", "host should be string"); + assert.strictEqual(typeof body.port, "number", "port should be number"); + assert.strictEqual(typeof body.collection, "string", "collection should be string"); + assert.strictEqual(typeof body.embeddingModel, "string", "embeddingModel should be string"); + assert.strictEqual(typeof body.hasApiKey, "boolean", "hasApiKey should be boolean"); + // No raw apiKey field in response + assert.strictEqual(body.apiKey, undefined, "raw apiKey must not be in response"); + // apiKeyMasked should be null when no key configured + assert.strictEqual(body.apiKeyMasked, null, "apiKeyMasked should be null when no key set"); +}); + +test("GET /api/settings/qdrant — 401 without auth", async () => { + await setRequireLogin(true); + const req = makeUnauthRequest("GET", "http://localhost/api/settings/qdrant"); + const res = await qdrantSettingsRoute.GET(req as any); + assert.strictEqual(res.status, 401); + await setRequireLogin(false); +}); + +// ── Settings PUT ── + +test("PUT /api/settings/qdrant — updates settings and returns new masked shape", async () => { + const req = await makeAuthRequest("PUT", "http://localhost/api/settings/qdrant", { + enabled: true, + host: "qdrant-server", + port: 6333, + collection: "test-collection", + embeddingModel: "openai/text-embedding-3-small", + }); + + const res = await qdrantSettingsRoute.PUT(req as any); + assert.strictEqual(res.status, 200); + + const body = await res.json(); + assert.strictEqual(body.enabled, true, "enabled should be true"); + assert.strictEqual(body.host, "qdrant-server", "host should be updated"); + assert.strictEqual(body.collection, "test-collection", "collection should be updated"); + assert.strictEqual(body.apiKey, undefined, "raw apiKey must not be in response"); +}); + +test("PUT /api/settings/qdrant — 400 invalid settings (invalid port type in strict schema)", async () => { + const req = await makeAuthRequest("PUT", "http://localhost/api/settings/qdrant", { + port: "not-a-number", + }); + + const res = await qdrantSettingsRoute.PUT(req as any); + assert.strictEqual(res.status, 400); + const body = await res.json(); + assert.ok(body.message || body.error, "should return error"); +}); + +test("PUT /api/settings/qdrant — 401 without auth", async () => { + await setRequireLogin(true); + const req = makeUnauthRequest("PUT", "http://localhost/api/settings/qdrant", { enabled: true }); + const res = await qdrantSettingsRoute.PUT(req as any); + assert.strictEqual(res.status, 401); + await setRequireLogin(false); +}); + +// ── Health ── + +test("GET /api/settings/qdrant/health — returns health result shape (qdrant disabled = not_configured)", async () => { + const headers = await createManagementSessionHeaders(); + const req = new Request("http://localhost/api/settings/qdrant/health", { + method: "GET", + headers: Object.fromEntries(headers.entries()), + }); + + const res = await qdrantHealthRoute.GET(req as any); + assert.strictEqual(res.status, 200); + + const body = await res.json(); + assert.strictEqual(typeof body.ok, "boolean", "ok should be boolean"); + assert.strictEqual(typeof body.latencyMs, "number", "latencyMs should be number"); + // When qdrant is disabled/unconfigured, ok=false with error "not_configured" + assert.strictEqual(body.ok, false, "ok should be false when qdrant not configured"); +}); + +test("GET /api/settings/qdrant/health — 401 without auth", async () => { + await setRequireLogin(true); + const req = makeUnauthRequest("GET", "http://localhost/api/settings/qdrant/health"); + const res = await qdrantHealthRoute.GET(req as any); + assert.strictEqual(res.status, 401); + await setRequireLogin(false); +}); + +// ── Search ── + +test("POST /api/settings/qdrant/search — returns ok + results array", async () => { + const req = await makeAuthRequest("POST", "http://localhost/api/settings/qdrant/search", { + query: "test query", + topK: 5, + }); + + const res = await qdrantSearchRoute.POST(req as any); + assert.strictEqual(res.status, 200); + + const body = await res.json(); + assert.strictEqual(typeof body.ok, "boolean", "ok should be boolean"); + assert.ok(Array.isArray(body.results), "results should be an array"); +}); + +test("POST /api/settings/qdrant/search — 400 invalid body (empty query)", async () => { + const req = await makeAuthRequest("POST", "http://localhost/api/settings/qdrant/search", { + query: "", + topK: 5, + }); + + const res = await qdrantSearchRoute.POST(req as any); + assert.strictEqual(res.status, 400); + const body = await res.json(); + assert.ok(body.message || body.error, "should return error"); +}); + +// ── Cleanup ── + +test("POST /api/settings/qdrant/cleanup — returns ok + deletedCount + retentionDays", async () => { + const req = await makeAuthRequest("POST", "http://localhost/api/settings/qdrant/cleanup"); + const res = await qdrantCleanupRoute.POST(req as any); + + assert.strictEqual(res.status, 200); + const body = await res.json(); + assert.strictEqual(typeof body.ok, "boolean", "ok should be boolean"); + assert.strictEqual(typeof body.deletedCount, "number", "deletedCount should be number"); + assert.strictEqual(typeof body.retentionDays, "number", "retentionDays should be number"); + assert.ok(body.retentionDays > 0, "retentionDays should be positive"); +}); + +// ── Embedding models ── + +test("GET /api/settings/qdrant/embedding-models — returns models array", async () => { + const headers = await createManagementSessionHeaders(); + const req = new Request("http://localhost/api/settings/qdrant/embedding-models", { + method: "GET", + headers: Object.fromEntries(headers.entries()), + }); + + const res = await qdrantEmbeddingModelsRoute.GET(req as any); + // 200 expected; verify shape + assert.strictEqual(res.status, 200); + const body = await res.json(); + assert.ok(Array.isArray(body.models), "should have models array"); + // Should have at least the default fallback model + assert.ok(body.models.length > 0, "should have at least one model"); + const defaultModel = body.models.find((m: any) => m.value === "openai/text-embedding-3-small"); + assert.ok(defaultModel, "should include openai/text-embedding-3-small as default"); +}); + +test("GET /api/settings/qdrant/embedding-models — 401 without auth", async () => { + await setRequireLogin(true); + const req = makeUnauthRequest("GET", "http://localhost/api/settings/qdrant/embedding-models"); + const res = await qdrantEmbeddingModelsRoute.GET(req as any); + assert.strictEqual(res.status, 401); + await setRequireLogin(false); +}); + +// ── Error sanitization ── + +test("Qdrant routes — error response has no stack trace in body", async () => { + // Test by sending malformed JSON to PUT settings — should return 400 without stack trace + const headers = await createManagementSessionHeaders(); + const req = new Request("http://localhost/api/settings/qdrant", { + method: "PUT", + headers: Object.fromEntries(headers.entries()), + body: "not-valid-json{{{", + }); + + const res = await qdrantSettingsRoute.PUT(req as any); + assert.ok(res.status >= 400, "should return error status"); + + const body = await res.json(); + const bodyStr = JSON.stringify(body); + // Hard Rule #12: no stack trace in response body + assert.ok(!bodyStr.match(/\sat\s\//), "response must not contain stack trace"); +});