mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
merge(F6): backend REST routes (memory + settings/qdrant)
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
18
src/app/api/memory/embedding-providers/route.ts
Normal file
18
src/app/api/memory/embedding-providers/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
18
src/app/api/memory/engine-status/route.ts
Normal file
18
src/app/api/memory/engine-status/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
54
src/app/api/memory/reindex/route.ts
Normal file
54
src/app/api/memory/reindex/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
58
src/app/api/memory/retrieve-preview/route.ts
Normal file
58
src/app/api/memory/retrieve-preview/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
42
src/app/api/memory/summarize/route.ts
Normal file
42
src/app/api/memory/summarize/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
26
src/app/api/settings/qdrant/cleanup/route.ts
Normal file
26
src/app/api/settings/qdrant/cleanup/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
18
src/app/api/settings/qdrant/health/route.ts
Normal file
18
src/app/api/settings/qdrant/health/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
86
src/app/api/settings/qdrant/route.ts
Normal file
86
src/app/api/settings/qdrant/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
40
src/app/api/settings/qdrant/search/route.ts
Normal file
40
src/app/api/settings/qdrant/search/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
91
tests/integration/memory-embedding-providers.test.ts
Normal file
91
tests/integration/memory-embedding-providers.test.ts
Normal 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);
|
||||
});
|
||||
103
tests/integration/memory-engine-status.test.ts
Normal file
103
tests/integration/memory-engine-status.test.ts
Normal 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);
|
||||
});
|
||||
112
tests/integration/memory-reindex.test.ts
Normal file
112
tests/integration/memory-reindex.test.ts
Normal 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);
|
||||
});
|
||||
127
tests/integration/memory-retrieve-preview.test.ts
Normal file
127
tests/integration/memory-retrieve-preview.test.ts
Normal 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");
|
||||
});
|
||||
143
tests/integration/memory-route-put.test.ts
Normal file
143
tests/integration/memory-route-put.test.ts
Normal 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");
|
||||
});
|
||||
149
tests/integration/memory-summarize.test.ts
Normal file
149
tests/integration/memory-summarize.test.ts
Normal 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);
|
||||
});
|
||||
280
tests/integration/qdrant-routes.test.ts
Normal file
280
tests/integration/qdrant-routes.test.ts
Normal 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");
|
||||
});
|
||||
Reference in New Issue
Block a user