merge(F6): backend REST routes (memory + settings/qdrant)

This commit is contained in:
diegosouzapw
2026-05-28 11:02:16 -03:00
19 changed files with 1433 additions and 34 deletions

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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<string, unknown>;
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<string, unknown>;
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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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<string, unknown>) {
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<string, unknown>;
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<string, unknown> = {};
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<string, unknown>;
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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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);
});

View File

@@ -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);
});

View File

@@ -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);
});

View File

@@ -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");
});

View File

@@ -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");
});

View File

@@ -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);
});

View File

@@ -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");
});