refactor: split llmModerationClient.ts + add tests + metrics
## Split llmModerationClient.ts (2103 → 3 files) - **moderationBuilders.ts** (67 lines) — shared: escapeXml, getAnalysisContent, buildReferenceXml - **mediaAnalysisClient.ts** (656 lines) — vision analysis with multi-layer LRU/DB/phash caching, image/video download, ffmpeg frame extraction, prepareMediaMessage - **moderationOrchestrator.ts** (998 lines) — callModerationLLM, runTextOnlyBatch, runMediaBatch, runModerationAnalysis, runSimpleTextFallback - **llmModerationClient.ts** (30 lines) — re-export bridge (backward compat) No import changes needed — aiAnalysisWorker.ts still imports from llmModerationClient.js. ## Unit tests (backend) - vitest.config.ts + e2e.test.ts with 9 tests against production: - health, metrics, dashboard/stats, recordings, config, auth, guilds, negative (404/400) ## Monitoring metrics - moderationMetrics.ts in backend health module: - LLM call count/duration/tokens - Cache hit/miss per layer - Media analysis count/download duration - Batch size distribution, errors, SearXNG, auto-delete
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* E2E API tests — runs against a running backend instance.
|
||||
* Usage: vitest run (or: API_BASE=http://localhost:3001 vitest run)
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
const BASE = process.env.API_BASE ?? "https://imphnen.asepharyana.my.id/api";
|
||||
|
||||
async function api(path: string, init?: RequestInit) {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
...init,
|
||||
headers: { "Content-Type": "application/json", ...init?.headers },
|
||||
});
|
||||
const body = res.status !== 204 ? await res.json().catch(() => null) : null;
|
||||
return { status: res.status, body };
|
||||
}
|
||||
|
||||
describe("API Health", () => {
|
||||
it("GET /health returns 200 with status=healthy", async () => {
|
||||
const { status, body } = await api("/health");
|
||||
expect(status).toBe(200);
|
||||
expect(body?.status).toBe("healthy");
|
||||
});
|
||||
|
||||
it("GET /metrics returns prometheus text", async () => {
|
||||
const res = await fetch(`${BASE.replace("/api", "")}/api/metrics`);
|
||||
expect(res.status).toBe(200);
|
||||
const text = await res.text();
|
||||
expect(text).toContain("nodejs");
|
||||
});
|
||||
});
|
||||
|
||||
describe("API Dashboard", () => {
|
||||
it("GET /dashboard/stats returns stats fields", async () => {
|
||||
const { status, body } = await api("/dashboard/stats");
|
||||
expect(status).toBe(200);
|
||||
expect(body).toHaveProperty("total_messages");
|
||||
expect(body).toHaveProperty("total_flagged");
|
||||
expect(body).toHaveProperty("active_users_24h");
|
||||
expect(typeof body.total_messages).toBe("number");
|
||||
});
|
||||
});
|
||||
|
||||
describe("API Recordings", () => {
|
||||
it("GET /recordings returns items with pagination", async () => {
|
||||
const { status, body } = await api("/recordings?limit=5");
|
||||
expect(status).toBe(200);
|
||||
expect(body).toHaveProperty("items");
|
||||
expect(Array.isArray(body.items)).toBe(true);
|
||||
if (body.items.length > 0) {
|
||||
expect(body.items[0]).toHaveProperty("id");
|
||||
expect(body.items[0]).toHaveProperty("username");
|
||||
expect(body.items[0]).toHaveProperty("created_at");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("API Config", () => {
|
||||
it("GET /config returns 200", async () => {
|
||||
const { status } = await api("/config");
|
||||
expect(status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("API Auth", () => {
|
||||
it("POST /auth/login with wrong password returns 401", async () => {
|
||||
const { status } = await api("/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ password: "wrong" }),
|
||||
});
|
||||
expect(status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("API Voice", () => {
|
||||
it("GET /guilds returns 200", async () => {
|
||||
const { status } = await api("/guilds");
|
||||
expect(status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("API Negative", () => {
|
||||
it("GET /nonexistent returns 404", async () => {
|
||||
const { status } = await api("/nonexistent");
|
||||
expect(status).toBe(404);
|
||||
});
|
||||
|
||||
it("GET /messages without channelId returns 400", async () => {
|
||||
const { status } = await api("/messages?limit=3");
|
||||
expect(status).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* moderationMetrics.ts
|
||||
*
|
||||
* Prometheus metrics for AI moderation pipeline.
|
||||
* Defined in backend (where prom-client is installed + /api/metrics endpoint).
|
||||
*/
|
||||
import { Counter, Histogram, register } from "prom-client";
|
||||
|
||||
// ── LLM Call Metrics ──
|
||||
export const llmCallsTotal = new Counter({
|
||||
name: "moderation_llm_calls_total",
|
||||
help: "Total LLM moderation calls",
|
||||
labelNames: ["path", "model"] as const,
|
||||
});
|
||||
|
||||
export const llmCallDuration = new Histogram({
|
||||
name: "moderation_llm_call_duration_ms",
|
||||
help: "LLM moderation call duration (ms)",
|
||||
labelNames: ["path", "status"] as const,
|
||||
buckets: [500, 1000, 2000, 5000, 10000, 20000, 30000, 60000, 120000],
|
||||
});
|
||||
|
||||
export const llmTokensTotal = new Counter({
|
||||
name: "moderation_llm_tokens_total",
|
||||
help: "Total tokens consumed by LLM moderation",
|
||||
labelNames: ["type"] as const,
|
||||
});
|
||||
|
||||
// ── Cache Metrics ──
|
||||
export const moderationCacheHits = new Counter({
|
||||
name: "moderation_cache_hits_total",
|
||||
help: "Moderation cache hits",
|
||||
labelNames: ["layer"] as const,
|
||||
});
|
||||
|
||||
export const moderationCacheMisses = new Counter({
|
||||
name: "moderation_cache_misses_total",
|
||||
help: "Moderation cache misses",
|
||||
labelNames: ["layer"] as const,
|
||||
});
|
||||
|
||||
// ── Media Analysis Metrics ──
|
||||
export const mediaAnalysesTotal = new Counter({
|
||||
name: "moderation_media_analyses_total",
|
||||
help: "Media analyses performed",
|
||||
labelNames: ["type"] as const,
|
||||
});
|
||||
|
||||
export const mediaDownloadDuration = new Histogram({
|
||||
name: "moderation_media_download_duration_ms",
|
||||
help: "Media download duration (ms)",
|
||||
labelNames: ["source"] as const,
|
||||
buckets: [100, 500, 1000, 2000, 5000, 10000, 30000],
|
||||
});
|
||||
|
||||
// ── Batch & Error Metrics ──
|
||||
export const moderationBatchSize = new Histogram({
|
||||
name: "moderation_batch_size",
|
||||
help: "Messages per batch",
|
||||
labelNames: ["path"] as const,
|
||||
buckets: [1, 5, 10, 20, 50, 100],
|
||||
});
|
||||
|
||||
export const moderationErrors = new Counter({
|
||||
name: "moderation_errors_total",
|
||||
help: "Moderation errors",
|
||||
labelNames: ["type"] as const,
|
||||
});
|
||||
|
||||
export const searxngCalls = new Counter({
|
||||
name: "moderation_searxng_calls_total",
|
||||
help: "SearXNG search calls",
|
||||
labelNames: ["status"] as const,
|
||||
});
|
||||
|
||||
export const autoDeleteActions = new Counter({
|
||||
name: "moderation_auto_delete_total",
|
||||
help: "Auto-delete actions",
|
||||
labelNames: ["action"] as const,
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
include: ["src/**/*.test.ts"],
|
||||
testTimeout: 15000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user