2026-05-14 21:16:03 +07:00
|
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
2026-05-14 19:16:46 +07:00
|
|
|
import {
|
|
|
|
|
parseModerationResponse,
|
|
|
|
|
runModerationAnalysis,
|
|
|
|
|
} from "../../src/moderation/llmModerationClient";
|
2026-05-14 21:16:03 +07:00
|
|
|
import type { MessageRecord } from "../../src/moderation/types";
|
2026-05-14 19:16:46 +07:00
|
|
|
|
|
|
|
|
vi.mock("../../src/retry", () => ({
|
2026-05-21 04:23:01 +07:00
|
|
|
retryWithBackoff: vi.fn(async (fn) => {
|
|
|
|
|
let lastError: unknown;
|
|
|
|
|
for (let attempt = 0; attempt < 4; attempt++) {
|
|
|
|
|
try {
|
|
|
|
|
return await fn();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
lastError = error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
throw lastError;
|
|
|
|
|
}),
|
2026-05-14 19:16:46 +07:00
|
|
|
}));
|
2026-05-14 19:16:46 +07:00
|
|
|
|
2026-05-14 19:28:56 +07:00
|
|
|
/**
|
|
|
|
|
* Helper to create a full MessageRecord fixture with sensible defaults.
|
|
|
|
|
* Only override fields that differ from defaults.
|
|
|
|
|
*/
|
|
|
|
|
function createMessageRecord(
|
|
|
|
|
overrides: Partial<MessageRecord> = {},
|
|
|
|
|
): MessageRecord {
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
return {
|
|
|
|
|
id: "m1",
|
|
|
|
|
guild_id: "guild123",
|
|
|
|
|
channel_id: "channel123",
|
|
|
|
|
thread_id: null,
|
|
|
|
|
user_id: "user123",
|
|
|
|
|
username: "user1",
|
|
|
|
|
avatar_url: null,
|
|
|
|
|
content: "hello",
|
|
|
|
|
edited_content: null,
|
|
|
|
|
created_at: now,
|
|
|
|
|
edited_at: null,
|
|
|
|
|
deleted_at: null,
|
|
|
|
|
type: "text",
|
|
|
|
|
metadata: null,
|
|
|
|
|
...overrides,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 19:16:46 +07:00
|
|
|
describe("parseModerationResponse", () => {
|
2026-05-14 19:16:46 +07:00
|
|
|
beforeEach(() => {
|
|
|
|
|
vi.clearAllMocks();
|
|
|
|
|
});
|
2026-05-14 19:16:46 +07:00
|
|
|
it("parses valid keyed results", () => {
|
|
|
|
|
const result = parseModerationResponse(
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
results: [
|
|
|
|
|
{
|
|
|
|
|
message_id: "m1",
|
|
|
|
|
status: "warn",
|
|
|
|
|
flags: ["provokasi"],
|
|
|
|
|
score: 0.7,
|
|
|
|
|
analysis: "Perlu peringatan.",
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
}),
|
|
|
|
|
["m1"],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(result).toEqual([
|
|
|
|
|
{
|
|
|
|
|
messageId: "m1",
|
|
|
|
|
status: "warn",
|
|
|
|
|
flags: ["provokasi"],
|
|
|
|
|
score: 0.7,
|
|
|
|
|
analysis: "Perlu peringatan.",
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-18 06:39:12 +07:00
|
|
|
it("handles missing target ids gracefully", () => {
|
|
|
|
|
const result = parseModerationResponse(JSON.stringify({ results: [] }), [
|
|
|
|
|
"m1",
|
|
|
|
|
]);
|
|
|
|
|
expect(result).toHaveLength(1);
|
|
|
|
|
expect(result[0].messageId).toBe("m1");
|
2026-05-21 02:39:28 +07:00
|
|
|
expect(result[0].status).toBe("error");
|
|
|
|
|
expect(result[0].flags).toEqual(["analysis_incomplete"]);
|
2026-05-18 06:39:12 +07:00
|
|
|
expect(result[0].score).toBe(0);
|
|
|
|
|
expect(result[0].analysis).toContain("incomplete");
|
2026-05-14 19:16:46 +07:00
|
|
|
});
|
|
|
|
|
|
2026-05-21 02:39:28 +07:00
|
|
|
it("skips unknown ids and fills missing targets", () => {
|
|
|
|
|
const result = parseModerationResponse(
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
results: [
|
|
|
|
|
{
|
|
|
|
|
message_id: "m2",
|
|
|
|
|
status: "clean",
|
|
|
|
|
flags: [],
|
|
|
|
|
score: 0,
|
|
|
|
|
analysis: "OK",
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
}),
|
|
|
|
|
["m1"],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(result).toHaveLength(1);
|
|
|
|
|
expect(result[0].messageId).toBe("m1");
|
|
|
|
|
expect(result[0].status).toBe("error");
|
|
|
|
|
expect(result[0].flags).toEqual(["analysis_incomplete"]);
|
|
|
|
|
expect(result[0].analysis).toContain("incomplete");
|
2026-05-14 19:16:46 +07:00
|
|
|
});
|
2026-05-14 19:16:46 +07:00
|
|
|
|
|
|
|
|
it("handles surrounding text around JSON", () => {
|
|
|
|
|
const content = `Some preamble text here.
|
|
|
|
|
{
|
|
|
|
|
"results": [
|
|
|
|
|
{
|
|
|
|
|
"message_id": "m1",
|
|
|
|
|
"status": "clean",
|
|
|
|
|
"flags": [],
|
|
|
|
|
"score": 0.1,
|
|
|
|
|
"analysis": "OK"
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
Some trailing text here.`;
|
|
|
|
|
|
|
|
|
|
const result = parseModerationResponse(content, ["m1"]);
|
|
|
|
|
expect(result).toHaveLength(1);
|
|
|
|
|
expect(result[0].messageId).toBe("m1");
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-21 04:23:01 +07:00
|
|
|
it("handles trailing JSON after first moderation object", () => {
|
|
|
|
|
const moderationJson = JSON.stringify({
|
|
|
|
|
results: [
|
|
|
|
|
{
|
|
|
|
|
message_id: "m1",
|
|
|
|
|
status: "clean",
|
|
|
|
|
flags: [],
|
|
|
|
|
score: 0.1,
|
|
|
|
|
analysis: "OK",
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
const trailingLogJson = JSON.stringify({ msg: "Retry attempt" });
|
|
|
|
|
|
|
|
|
|
const result = parseModerationResponse(
|
|
|
|
|
`${moderationJson}\n${trailingLogJson}`,
|
|
|
|
|
["m1"],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(result).toHaveLength(1);
|
|
|
|
|
expect(result[0].messageId).toBe("m1");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("handles braces inside string values", () => {
|
|
|
|
|
const result = parseModerationResponse(
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
results: [
|
|
|
|
|
{
|
|
|
|
|
message_id: "m1",
|
|
|
|
|
status: "clean",
|
|
|
|
|
flags: [],
|
|
|
|
|
score: 0.1,
|
|
|
|
|
analysis: "Contains literal braces: {not json}",
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
}),
|
|
|
|
|
["m1"],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(result).toHaveLength(1);
|
|
|
|
|
expect(result[0].analysis).toBe("Contains literal braces: {not json}");
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-14 19:16:46 +07:00
|
|
|
it("handles nested fields in results", () => {
|
|
|
|
|
const content = JSON.stringify({
|
|
|
|
|
results: [
|
|
|
|
|
{
|
|
|
|
|
message_id: "m1",
|
|
|
|
|
status: "warn",
|
|
|
|
|
flags: ["spam", "abuse"],
|
|
|
|
|
score: 0.85,
|
|
|
|
|
analysis: "Multiple violations detected",
|
|
|
|
|
metadata: {
|
|
|
|
|
nested: "field",
|
|
|
|
|
count: 5,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const result = parseModerationResponse(content, ["m1"]);
|
|
|
|
|
expect(result).toHaveLength(1);
|
|
|
|
|
expect(result[0].score).toBe(0.85);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("rejects null score", () => {
|
|
|
|
|
expect(() =>
|
|
|
|
|
parseModerationResponse(
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
results: [
|
|
|
|
|
{
|
|
|
|
|
message_id: "m1",
|
|
|
|
|
status: "clean",
|
|
|
|
|
flags: [],
|
|
|
|
|
score: null,
|
|
|
|
|
analysis: "OK",
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
}),
|
|
|
|
|
["m1"],
|
|
|
|
|
),
|
|
|
|
|
).toThrow(/null or undefined/i);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("rejects undefined score", () => {
|
|
|
|
|
expect(() =>
|
|
|
|
|
parseModerationResponse(
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
results: [
|
|
|
|
|
{
|
|
|
|
|
message_id: "m1",
|
|
|
|
|
status: "clean",
|
|
|
|
|
flags: [],
|
|
|
|
|
analysis: "OK",
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
}),
|
|
|
|
|
["m1"],
|
|
|
|
|
),
|
|
|
|
|
).toThrow(/null or undefined/i);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("rejects duplicate message_id", () => {
|
|
|
|
|
expect(() =>
|
|
|
|
|
parseModerationResponse(
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
results: [
|
|
|
|
|
{
|
|
|
|
|
message_id: "m1",
|
|
|
|
|
status: "clean",
|
|
|
|
|
flags: [],
|
|
|
|
|
score: 0.1,
|
|
|
|
|
analysis: "OK",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
message_id: "m1",
|
|
|
|
|
status: "warn",
|
|
|
|
|
flags: ["spam"],
|
|
|
|
|
score: 0.5,
|
|
|
|
|
analysis: "Duplicate",
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
}),
|
|
|
|
|
["m1"],
|
|
|
|
|
),
|
|
|
|
|
).toThrow(/duplicate/i);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("rejects invalid status", () => {
|
|
|
|
|
expect(() =>
|
|
|
|
|
parseModerationResponse(
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
results: [
|
|
|
|
|
{
|
|
|
|
|
message_id: "m1",
|
|
|
|
|
status: "invalid_status",
|
|
|
|
|
flags: [],
|
|
|
|
|
score: 0.5,
|
|
|
|
|
analysis: "OK",
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
}),
|
|
|
|
|
["m1"],
|
|
|
|
|
),
|
|
|
|
|
).toThrow(/invalid status/i);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("clamps score to 0-1 range", () => {
|
|
|
|
|
const result = parseModerationResponse(
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
results: [
|
|
|
|
|
{
|
|
|
|
|
message_id: "m1",
|
|
|
|
|
status: "clean",
|
|
|
|
|
flags: [],
|
|
|
|
|
score: 1.5,
|
|
|
|
|
analysis: "OK",
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
}),
|
|
|
|
|
["m1"],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(result[0].score).toBe(1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("clamps negative score to 0", () => {
|
|
|
|
|
const result = parseModerationResponse(
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
results: [
|
|
|
|
|
{
|
|
|
|
|
message_id: "m1",
|
|
|
|
|
status: "clean",
|
|
|
|
|
flags: [],
|
|
|
|
|
score: -0.5,
|
|
|
|
|
analysis: "OK",
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
}),
|
|
|
|
|
["m1"],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(result[0].score).toBe(0);
|
|
|
|
|
});
|
2026-05-18 04:38:30 +07:00
|
|
|
|
|
|
|
|
it("extracts JSON correctly from complex conversational output with thinking blocks containing braces", () => {
|
|
|
|
|
const content = `Based on the messages, I will analyze them.
|
|
|
|
|
<thinking>
|
|
|
|
|
The JSON structure should be:
|
|
|
|
|
{
|
|
|
|
|
"results": [ ... ]
|
|
|
|
|
}
|
|
|
|
|
</thinking>
|
|
|
|
|
Here is the results array:
|
|
|
|
|
{
|
|
|
|
|
"results": [
|
|
|
|
|
{
|
|
|
|
|
"message_id": "m1",
|
|
|
|
|
"status": "clean",
|
|
|
|
|
"flags": [],
|
|
|
|
|
"score": 0.2,
|
|
|
|
|
"analysis": "Benign"
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
}`;
|
|
|
|
|
const result = parseModerationResponse(content, ["m1"]);
|
|
|
|
|
expect(result).toHaveLength(1);
|
|
|
|
|
expect(result[0].messageId).toBe("m1");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("extracts JSON from markdown code block wrapping", () => {
|
|
|
|
|
const content = `Sure! Here is the JSON structure:
|
|
|
|
|
\`\`\`json
|
|
|
|
|
{
|
|
|
|
|
"results": [
|
|
|
|
|
{
|
|
|
|
|
"message_id": "m1",
|
|
|
|
|
"status": "clean",
|
|
|
|
|
"flags": [],
|
|
|
|
|
"score": 0.2,
|
|
|
|
|
"analysis": "Benign"
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
\`\`\``;
|
|
|
|
|
const result = parseModerationResponse(content, ["m1"]);
|
|
|
|
|
expect(result).toHaveLength(1);
|
|
|
|
|
expect(result[0].messageId).toBe("m1");
|
|
|
|
|
});
|
2026-05-18 04:40:54 +07:00
|
|
|
|
|
|
|
|
it("handles message_id returned with square brackets", () => {
|
|
|
|
|
const content = JSON.stringify({
|
|
|
|
|
results: [
|
|
|
|
|
{
|
|
|
|
|
message_id: "[m1]",
|
|
|
|
|
status: "clean",
|
|
|
|
|
flags: [],
|
|
|
|
|
score: 0.1,
|
|
|
|
|
analysis: "OK",
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
const result = parseModerationResponse(content, ["m1"]);
|
|
|
|
|
expect(result).toHaveLength(1);
|
|
|
|
|
expect(result[0].messageId).toBe("m1");
|
|
|
|
|
});
|
2026-05-19 00:24:13 +07:00
|
|
|
|
|
|
|
|
it("handles message_id returned with extra wrapping quotes", () => {
|
|
|
|
|
const result = parseModerationResponse(
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
results: [
|
|
|
|
|
{
|
|
|
|
|
message_id: '"test-msg-1"',
|
|
|
|
|
status: "clean",
|
|
|
|
|
flags: [],
|
|
|
|
|
score: 0.1,
|
|
|
|
|
analysis: "OK",
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
}),
|
|
|
|
|
["test-msg-1"],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(result).toHaveLength(1);
|
|
|
|
|
expect(result[0].messageId).toBe("test-msg-1");
|
|
|
|
|
});
|
2026-05-14 19:16:46 +07:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("runModerationAnalysis", () => {
|
|
|
|
|
it("parses successful response from LLM", async () => {
|
|
|
|
|
const mockResponse = {
|
|
|
|
|
choices: [
|
|
|
|
|
{
|
|
|
|
|
message: {
|
|
|
|
|
content: JSON.stringify({
|
|
|
|
|
results: [
|
|
|
|
|
{
|
|
|
|
|
message_id: "m1",
|
|
|
|
|
status: "clean",
|
|
|
|
|
flags: [],
|
|
|
|
|
score: 0.1,
|
|
|
|
|
analysis: "OK",
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
}),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
global.fetch = vi.fn().mockResolvedValue({
|
|
|
|
|
ok: true,
|
|
|
|
|
json: async () => mockResponse,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const result = await runModerationAnalysis({
|
2026-05-14 19:28:56 +07:00
|
|
|
targets: [createMessageRecord()],
|
2026-05-14 19:16:46 +07:00
|
|
|
contextText: "test context",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(result.results).toHaveLength(1);
|
|
|
|
|
expect(result.results[0].messageId).toBe("m1");
|
|
|
|
|
expect(result.raw).toEqual(mockResponse);
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-19 00:24:13 +07:00
|
|
|
it("requests strict JSON output without thinking", async () => {
|
|
|
|
|
const mockResponse = {
|
|
|
|
|
choices: [
|
|
|
|
|
{
|
|
|
|
|
message: {
|
|
|
|
|
content: JSON.stringify({
|
|
|
|
|
results: [
|
|
|
|
|
{
|
|
|
|
|
message_id: "m1",
|
|
|
|
|
status: "clean",
|
|
|
|
|
flags: [],
|
|
|
|
|
score: 0.1,
|
|
|
|
|
analysis: "OK",
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
}),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
global.fetch = vi.fn().mockResolvedValue({
|
|
|
|
|
ok: true,
|
|
|
|
|
text: async () => JSON.stringify(mockResponse),
|
|
|
|
|
json: async () => mockResponse,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await runModerationAnalysis({
|
|
|
|
|
targets: [createMessageRecord()],
|
|
|
|
|
contextText: "test context",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const requestBody = JSON.parse((global.fetch as any).mock.calls[0][1].body);
|
2026-05-21 02:39:28 +07:00
|
|
|
expect(requestBody.temperature).toBe(0.2);
|
2026-05-19 00:24:13 +07:00
|
|
|
expect(requestBody.response_format).toEqual({ type: "json_object" });
|
2026-05-21 02:39:28 +07:00
|
|
|
expect(requestBody.stream).toBe(false);
|
|
|
|
|
expect(requestBody.reasoning_budget).toBe(0);
|
2026-05-19 02:49:55 +07:00
|
|
|
expect(requestBody.chat_template_kwargs).toEqual({
|
|
|
|
|
enable_thinking: false,
|
|
|
|
|
});
|
2026-05-19 00:24:13 +07:00
|
|
|
});
|
|
|
|
|
|
2026-05-21 01:55:50 +07:00
|
|
|
it("sends text-only analysis without dummy image", async () => {
|
|
|
|
|
const mockResponse = {
|
|
|
|
|
choices: [
|
|
|
|
|
{
|
|
|
|
|
message: {
|
|
|
|
|
content: JSON.stringify({
|
|
|
|
|
results: [
|
|
|
|
|
{
|
|
|
|
|
message_id: "m1",
|
|
|
|
|
status: "clean",
|
|
|
|
|
flags: [],
|
|
|
|
|
score: 0.1,
|
|
|
|
|
analysis: "OK",
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
}),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
global.fetch = vi.fn().mockResolvedValue({
|
|
|
|
|
ok: true,
|
|
|
|
|
text: async () => JSON.stringify(mockResponse),
|
|
|
|
|
json: async () => mockResponse,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await runModerationAnalysis({
|
|
|
|
|
targets: [createMessageRecord()],
|
|
|
|
|
contextText: "test context",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const requestBody = JSON.parse((global.fetch as any).mock.calls[0][1].body);
|
2026-05-21 02:56:41 +07:00
|
|
|
expect(requestBody.messages).toHaveLength(1);
|
|
|
|
|
expect(requestBody.messages[0].role).toBe("user");
|
|
|
|
|
expect(typeof requestBody.messages[0].content).toBe("string");
|
|
|
|
|
expect(requestBody.messages[0].content).toContain("test context");
|
|
|
|
|
expect(requestBody.messages[0].content).not.toContain("data:image/png");
|
2026-05-21 01:55:50 +07:00
|
|
|
});
|
|
|
|
|
|
2026-05-14 19:16:46 +07:00
|
|
|
it("throws on non-ok HTTP response", async () => {
|
|
|
|
|
global.fetch = vi.fn().mockResolvedValue({
|
|
|
|
|
ok: false,
|
|
|
|
|
status: 500,
|
|
|
|
|
text: async () => "Internal Server Error",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await expect(
|
|
|
|
|
runModerationAnalysis({
|
2026-05-14 19:28:56 +07:00
|
|
|
targets: [createMessageRecord()],
|
2026-05-14 19:16:46 +07:00
|
|
|
contextText: "test context",
|
|
|
|
|
}),
|
2026-05-21 02:39:28 +07:00
|
|
|
).rejects.toThrow(/500/);
|
2026-05-14 19:16:46 +07:00
|
|
|
});
|
|
|
|
|
|
2026-05-21 03:52:51 +07:00
|
|
|
it("parses first JSON object when provider appends extra text to message content", async () => {
|
2026-05-21 02:39:28 +07:00
|
|
|
const moderationJson = JSON.stringify({
|
|
|
|
|
results: [
|
|
|
|
|
{
|
|
|
|
|
message_id: "m1",
|
|
|
|
|
status: "clean",
|
|
|
|
|
flags: [],
|
|
|
|
|
score: 0.1,
|
|
|
|
|
analysis: "OK",
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
});
|
2026-05-21 01:55:50 +07:00
|
|
|
const mockResponse = {
|
|
|
|
|
choices: [
|
|
|
|
|
{
|
|
|
|
|
message: {
|
2026-05-21 02:39:28 +07:00
|
|
|
content: `${moderationJson}\nextra`,
|
2026-05-21 01:55:50 +07:00
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
global.fetch = vi.fn().mockResolvedValue({
|
|
|
|
|
ok: true,
|
2026-05-21 02:39:28 +07:00
|
|
|
text: async () => JSON.stringify(mockResponse),
|
2026-05-21 01:55:50 +07:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const result = await runModerationAnalysis({
|
|
|
|
|
targets: [createMessageRecord()],
|
|
|
|
|
contextText: "test context",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(result.results).toHaveLength(1);
|
|
|
|
|
expect(result.results[0].messageId).toBe("m1");
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-21 03:52:51 +07:00
|
|
|
it("normalizes first JSON object when provider appends extra text to HTTP body", async () => {
|
|
|
|
|
const mockResponse = {
|
|
|
|
|
choices: [
|
|
|
|
|
{
|
|
|
|
|
message: {
|
|
|
|
|
content: JSON.stringify({
|
|
|
|
|
results: [
|
|
|
|
|
{
|
|
|
|
|
message_id: "m1",
|
|
|
|
|
status: "clean",
|
|
|
|
|
flags: [],
|
|
|
|
|
score: 0.1,
|
|
|
|
|
analysis: "OK",
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
}),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
global.fetch = vi.fn().mockResolvedValue({
|
|
|
|
|
ok: true,
|
2026-05-21 04:00:36 +07:00
|
|
|
status: 200,
|
|
|
|
|
headers: new Headers({ "Content-Type": "application/json" }),
|
2026-05-21 03:52:51 +07:00
|
|
|
text: async () => `${JSON.stringify(mockResponse)}\nextra`,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const result = await runModerationAnalysis({
|
|
|
|
|
targets: [createMessageRecord()],
|
|
|
|
|
contextText: "test context",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(result.results).toHaveLength(1);
|
|
|
|
|
expect(result.results[0].messageId).toBe("m1");
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-21 04:23:01 +07:00
|
|
|
it("includes previous validation error in retry prompt", async () => {
|
|
|
|
|
const invalidResponse = {
|
|
|
|
|
choices: [
|
|
|
|
|
{
|
|
|
|
|
message: {
|
|
|
|
|
content: JSON.stringify({
|
|
|
|
|
results: [
|
|
|
|
|
{
|
|
|
|
|
message_id: "m1",
|
|
|
|
|
status: "bad",
|
|
|
|
|
flags: [],
|
|
|
|
|
score: 0.1,
|
|
|
|
|
analysis: "Invalid",
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
}),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
};
|
|
|
|
|
const validResponse = {
|
|
|
|
|
choices: [
|
|
|
|
|
{
|
|
|
|
|
message: {
|
|
|
|
|
content: JSON.stringify({
|
|
|
|
|
results: [
|
|
|
|
|
{
|
|
|
|
|
message_id: "m1",
|
|
|
|
|
status: "clean",
|
|
|
|
|
flags: [],
|
|
|
|
|
score: 0.1,
|
|
|
|
|
analysis: "OK",
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
}),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
global.fetch = vi
|
|
|
|
|
.fn()
|
|
|
|
|
.mockResolvedValueOnce({
|
|
|
|
|
ok: true,
|
|
|
|
|
text: async () => JSON.stringify(invalidResponse),
|
|
|
|
|
})
|
|
|
|
|
.mockResolvedValueOnce({
|
|
|
|
|
ok: true,
|
|
|
|
|
text: async () => JSON.stringify(validResponse),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const result = await runModerationAnalysis({
|
|
|
|
|
targets: [createMessageRecord()],
|
|
|
|
|
contextText: "test context",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const secondRequestBody = JSON.parse(
|
|
|
|
|
(global.fetch as any).mock.calls[1][1].body,
|
|
|
|
|
);
|
|
|
|
|
expect(secondRequestBody.messages[0].content).toContain(
|
|
|
|
|
"Previous response failed validation",
|
|
|
|
|
);
|
|
|
|
|
expect(secondRequestBody.messages[0].content).toContain(
|
|
|
|
|
"Invalid status: bad",
|
|
|
|
|
);
|
|
|
|
|
expect(secondRequestBody.messages[0].content).toContain(
|
|
|
|
|
"Retry with corrected output",
|
|
|
|
|
);
|
|
|
|
|
expect(result.results[0].status).toBe("clean");
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-14 19:16:46 +07:00
|
|
|
it("throws on missing choices in response", async () => {
|
|
|
|
|
global.fetch = vi.fn().mockResolvedValue({
|
|
|
|
|
ok: true,
|
|
|
|
|
json: async () => ({}),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await expect(
|
|
|
|
|
runModerationAnalysis({
|
2026-05-14 19:28:56 +07:00
|
|
|
targets: [createMessageRecord()],
|
2026-05-14 19:16:46 +07:00
|
|
|
contextText: "test context",
|
|
|
|
|
}),
|
|
|
|
|
).rejects.toThrow(/Invalid LLM response structure/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("throws on missing content in message", async () => {
|
|
|
|
|
global.fetch = vi.fn().mockResolvedValue({
|
|
|
|
|
ok: true,
|
|
|
|
|
json: async () => ({
|
|
|
|
|
choices: [{ message: {} }],
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await expect(
|
|
|
|
|
runModerationAnalysis({
|
2026-05-14 19:28:56 +07:00
|
|
|
targets: [createMessageRecord()],
|
2026-05-14 19:16:46 +07:00
|
|
|
contextText: "test context",
|
|
|
|
|
}),
|
|
|
|
|
).rejects.toThrow(/No content in LLM response/);
|
|
|
|
|
});
|
2026-05-17 23:56:04 +07:00
|
|
|
|
|
|
|
|
it("sends multimodal payload when image attachments are present", async () => {
|
|
|
|
|
const mockResponse = {
|
|
|
|
|
choices: [
|
|
|
|
|
{
|
|
|
|
|
message: {
|
|
|
|
|
content: JSON.stringify({
|
|
|
|
|
results: [
|
|
|
|
|
{
|
|
|
|
|
message_id: "m1",
|
|
|
|
|
status: "clean",
|
|
|
|
|
flags: [],
|
|
|
|
|
score: 0.1,
|
|
|
|
|
analysis: "OK",
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
}),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
global.fetch = vi.fn().mockImplementation((url: string) => {
|
2026-05-19 00:07:31 +07:00
|
|
|
if (url === "https://httpbin.org/image/png") {
|
2026-05-17 23:56:04 +07:00
|
|
|
return Promise.resolve({
|
|
|
|
|
ok: true,
|
|
|
|
|
arrayBuffer: async () => {
|
|
|
|
|
const buffer = Buffer.from("fake-image-bytes");
|
|
|
|
|
return buffer.buffer.slice(
|
|
|
|
|
buffer.byteOffset,
|
|
|
|
|
buffer.byteOffset + buffer.byteLength,
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return Promise.resolve({
|
|
|
|
|
ok: true,
|
|
|
|
|
text: async () => JSON.stringify(mockResponse),
|
|
|
|
|
json: async () => mockResponse,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const mockAttachment = {
|
|
|
|
|
id: "a1",
|
|
|
|
|
message_id: "m1",
|
|
|
|
|
guild_id: "guild123",
|
|
|
|
|
channel_id: "channel123",
|
|
|
|
|
thread_id: null,
|
|
|
|
|
user_id: "user123",
|
|
|
|
|
filename: "test.png",
|
|
|
|
|
size: 500,
|
|
|
|
|
type: "image/png",
|
2026-05-19 00:07:31 +07:00
|
|
|
discord_url: "https://httpbin.org/image/png",
|
|
|
|
|
uploaded_url: "https://httpbin.org/image/png",
|
2026-05-17 23:56:04 +07:00
|
|
|
upload_status: "uploaded" as const,
|
|
|
|
|
upload_error: null,
|
|
|
|
|
created_at: Date.now(),
|
|
|
|
|
uploaded_at: Date.now(),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const result = await runModerationAnalysis({
|
|
|
|
|
targets: [createMessageRecord()],
|
|
|
|
|
contextText: "test context",
|
|
|
|
|
attachments: [mockAttachment],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(result.results).toHaveLength(1);
|
|
|
|
|
expect(global.fetch).toHaveBeenCalled();
|
|
|
|
|
|
|
|
|
|
const fetchCalls = (global.fetch as any).mock.calls;
|
|
|
|
|
// Should be called twice: 1st for image download, 2nd for API completions
|
|
|
|
|
expect(fetchCalls.length).toBe(2);
|
|
|
|
|
|
|
|
|
|
// Verify 1st call (image download)
|
2026-05-19 00:07:31 +07:00
|
|
|
expect(fetchCalls[0][0]).toBe("https://httpbin.org/image/png");
|
2026-05-17 23:56:04 +07:00
|
|
|
|
|
|
|
|
// Verify 2nd call (chat completions API)
|
|
|
|
|
const [, completionsOptions] = fetchCalls[1];
|
|
|
|
|
const body = JSON.parse(completionsOptions.body);
|
|
|
|
|
|
2026-05-21 02:56:41 +07:00
|
|
|
expect(body.messages).toHaveLength(1);
|
|
|
|
|
const userMessage = body.messages[0];
|
2026-05-17 23:56:04 +07:00
|
|
|
expect(userMessage.role).toBe("user");
|
|
|
|
|
expect(Array.isArray(userMessage.content)).toBe(true);
|
|
|
|
|
expect(userMessage.content[0].type).toBe("image_url");
|
|
|
|
|
expect(userMessage.content[0].image_url.url).toContain(
|
|
|
|
|
"data:image/png;base64,",
|
|
|
|
|
);
|
|
|
|
|
expect(userMessage.content[1].type).toBe("text");
|
|
|
|
|
expect(userMessage.content[1].text).toContain(
|
|
|
|
|
"Image Attachment for Message ID: m1",
|
|
|
|
|
);
|
|
|
|
|
expect(userMessage.content[2].type).toBe("text");
|
2026-05-21 01:55:50 +07:00
|
|
|
expect(userMessage.content[2].text).toContain("test context");
|
2026-05-21 02:56:41 +07:00
|
|
|
expect(userMessage.content[2].text).toContain(
|
2026-05-17 23:56:04 +07:00
|
|
|
"You are a content moderation assistant.",
|
|
|
|
|
);
|
|
|
|
|
});
|
2026-05-18 04:38:30 +07:00
|
|
|
|
|
|
|
|
it("caps image attachments to 8 and prioritizes targets over context", async () => {
|
|
|
|
|
const mockResponse = {
|
|
|
|
|
choices: [
|
|
|
|
|
{
|
|
|
|
|
message: {
|
|
|
|
|
content: JSON.stringify({
|
|
|
|
|
results: [
|
|
|
|
|
{
|
|
|
|
|
message_id: "m1",
|
|
|
|
|
status: "clean",
|
|
|
|
|
flags: [],
|
|
|
|
|
score: 0.1,
|
|
|
|
|
analysis: "OK",
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
}),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
global.fetch = vi.fn().mockImplementation((url: string) => {
|
2026-05-19 00:07:31 +07:00
|
|
|
if (url.startsWith("https://httpbin.org/image/")) {
|
2026-05-18 04:38:30 +07:00
|
|
|
return Promise.resolve({
|
|
|
|
|
ok: true,
|
|
|
|
|
arrayBuffer: async () => {
|
|
|
|
|
const buffer = Buffer.from("fake-bytes");
|
|
|
|
|
return buffer.buffer.slice(
|
|
|
|
|
buffer.byteOffset,
|
|
|
|
|
buffer.byteOffset + buffer.byteLength,
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return Promise.resolve({
|
|
|
|
|
ok: true,
|
|
|
|
|
text: async () => JSON.stringify(mockResponse),
|
|
|
|
|
json: async () => mockResponse,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-18 04:40:54 +07:00
|
|
|
const createAttachment = (
|
|
|
|
|
id: string,
|
|
|
|
|
msgId: string,
|
|
|
|
|
createdAt: number,
|
|
|
|
|
) => ({
|
2026-05-18 04:38:30 +07:00
|
|
|
id,
|
|
|
|
|
message_id: msgId,
|
|
|
|
|
guild_id: "guild123",
|
|
|
|
|
channel_id: "channel123",
|
|
|
|
|
thread_id: null,
|
|
|
|
|
user_id: "user123",
|
|
|
|
|
filename: `${id}.png`,
|
|
|
|
|
size: 500,
|
|
|
|
|
type: "image/png",
|
2026-05-19 00:07:31 +07:00
|
|
|
discord_url: `https://httpbin.org/image/png?source=${id}`,
|
|
|
|
|
uploaded_url: `https://httpbin.org/image/png?source=${id}`,
|
2026-05-18 04:38:30 +07:00
|
|
|
upload_status: "uploaded" as const,
|
|
|
|
|
upload_error: null,
|
|
|
|
|
created_at: createdAt,
|
|
|
|
|
uploaded_at: createdAt,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 10 attachments total (3 targets, 7 context)
|
|
|
|
|
const attachments = [
|
|
|
|
|
createAttachment("c1", "context1", 100),
|
|
|
|
|
createAttachment("c2", "context2", 200),
|
|
|
|
|
createAttachment("t1", "m1", 300), // Target 1
|
|
|
|
|
createAttachment("c3", "context3", 400),
|
|
|
|
|
createAttachment("t2", "m1", 500), // Target 2
|
|
|
|
|
createAttachment("c4", "context4", 600),
|
|
|
|
|
createAttachment("c5", "context5", 700),
|
|
|
|
|
createAttachment("t3", "m1", 800), // Target 3
|
|
|
|
|
createAttachment("c6", "context6", 900),
|
|
|
|
|
createAttachment("c7", "context7", 1000),
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
await runModerationAnalysis({
|
|
|
|
|
targets: [createMessageRecord({ id: "m1" })],
|
|
|
|
|
contextText: "test context",
|
|
|
|
|
attachments,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const fetchCalls = (global.fetch as any).mock.calls;
|
|
|
|
|
// Should download exactly 8 images (since it's capped at 8) plus 1 call for completion API = 9 calls total.
|
|
|
|
|
expect(fetchCalls.length).toBe(9);
|
|
|
|
|
|
|
|
|
|
// Target attachments (t3, t2, t1) must be fetched, then context in descending order of created_at:
|
|
|
|
|
// Sorted order: t3 (800), t2 (500), t1 (300), c7 (1000), c6 (900), c5 (700), c4 (600), c3 (400)
|
|
|
|
|
// Excluded: c2 (200), c1 (100)
|
2026-05-18 04:40:54 +07:00
|
|
|
const downloadedUrls = fetchCalls.slice(0, 8).map((call: any) => call[0]);
|
2026-05-18 04:38:30 +07:00
|
|
|
|
2026-05-19 00:07:31 +07:00
|
|
|
expect(downloadedUrls).toContain("https://httpbin.org/image/png?source=t3");
|
|
|
|
|
expect(downloadedUrls).toContain("https://httpbin.org/image/png?source=t2");
|
|
|
|
|
expect(downloadedUrls).toContain("https://httpbin.org/image/png?source=t1");
|
|
|
|
|
expect(downloadedUrls).toContain("https://httpbin.org/image/png?source=c7");
|
2026-05-19 02:49:55 +07:00
|
|
|
expect(downloadedUrls).not.toContain(
|
|
|
|
|
"https://httpbin.org/image/png?source=c1",
|
|
|
|
|
);
|
2026-05-19 00:07:31 +07:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("sends verified real PNG and JPEG attachments with realistic Indonesian text", async () => {
|
|
|
|
|
const mockResponse = {
|
|
|
|
|
choices: [
|
|
|
|
|
{
|
|
|
|
|
message: {
|
|
|
|
|
content: JSON.stringify({
|
|
|
|
|
results: [
|
|
|
|
|
{
|
|
|
|
|
message_id: "m1",
|
|
|
|
|
status: "warn",
|
|
|
|
|
flags: ["harassment"],
|
|
|
|
|
score: 0.65,
|
|
|
|
|
analysis: "Teks dan gambar perlu ditinjau moderator.",
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
}),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const imageBytes = Buffer.from("realistic-image-bytes");
|
|
|
|
|
global.fetch = vi.fn().mockImplementation((url: string) => {
|
|
|
|
|
if (
|
|
|
|
|
url === "https://httpbin.org/image/png" ||
|
|
|
|
|
url === "https://httpbin.org/image/jpeg"
|
|
|
|
|
) {
|
|
|
|
|
return Promise.resolve({
|
|
|
|
|
ok: true,
|
|
|
|
|
arrayBuffer: async () =>
|
|
|
|
|
imageBytes.buffer.slice(
|
|
|
|
|
imageBytes.byteOffset,
|
|
|
|
|
imageBytes.byteOffset + imageBytes.byteLength,
|
|
|
|
|
),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Promise.resolve({
|
|
|
|
|
ok: true,
|
|
|
|
|
text: async () => JSON.stringify(mockResponse),
|
|
|
|
|
json: async () => mockResponse,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const attachments = [
|
|
|
|
|
{
|
|
|
|
|
id: "png1",
|
|
|
|
|
message_id: "m1",
|
|
|
|
|
guild_id: "guild123",
|
|
|
|
|
channel_id: "channel123",
|
|
|
|
|
thread_id: null,
|
|
|
|
|
user_id: "user123",
|
|
|
|
|
filename: "bukti-chat.png",
|
|
|
|
|
size: 8090,
|
|
|
|
|
type: "image/png",
|
|
|
|
|
discord_url: "https://httpbin.org/image/png",
|
|
|
|
|
uploaded_url: "https://httpbin.org/image/png",
|
|
|
|
|
upload_status: "uploaded" as const,
|
|
|
|
|
upload_error: null,
|
|
|
|
|
created_at: Date.now(),
|
|
|
|
|
uploaded_at: Date.now(),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: "jpeg1",
|
|
|
|
|
message_id: "m1",
|
|
|
|
|
guild_id: "guild123",
|
|
|
|
|
channel_id: "channel123",
|
|
|
|
|
thread_id: null,
|
|
|
|
|
user_id: "user123",
|
|
|
|
|
filename: "screenshot.jpeg",
|
|
|
|
|
size: 35588,
|
|
|
|
|
type: "image/jpeg",
|
|
|
|
|
discord_url: "https://httpbin.org/image/jpeg",
|
|
|
|
|
uploaded_url: "https://httpbin.org/image/jpeg",
|
|
|
|
|
upload_status: "uploaded" as const,
|
|
|
|
|
upload_error: null,
|
|
|
|
|
created_at: Date.now() + 1,
|
|
|
|
|
uploaded_at: Date.now() + 1,
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const result = await runModerationAnalysis({
|
|
|
|
|
targets: [
|
|
|
|
|
createMessageRecord({
|
|
|
|
|
id: "m1",
|
|
|
|
|
username: "asep",
|
|
|
|
|
content:
|
|
|
|
|
"tolong cek gambar ini, dia kirim link mencurigakan https://example.invalid/login dan maksa orang klik",
|
|
|
|
|
}),
|
|
|
|
|
],
|
|
|
|
|
contextText:
|
|
|
|
|
"Sebelumnya user lain bilang link itu mirip phishing dan screenshot memperlihatkan halaman login palsu.",
|
|
|
|
|
attachments,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(result.results[0].status).toBe("warn");
|
|
|
|
|
|
|
|
|
|
const fetchCalls = (global.fetch as any).mock.calls;
|
|
|
|
|
expect(fetchCalls[0][0]).toBe("https://httpbin.org/image/jpeg");
|
|
|
|
|
expect(fetchCalls[1][0]).toBe("https://httpbin.org/image/png");
|
|
|
|
|
|
|
|
|
|
const requestBody = JSON.parse(fetchCalls[2][1].body);
|
2026-05-21 02:56:41 +07:00
|
|
|
const contentParts = requestBody.messages[0].content;
|
2026-05-19 02:49:55 +07:00
|
|
|
expect(
|
|
|
|
|
contentParts.filter((part: any) => part.type === "image_url"),
|
|
|
|
|
).toHaveLength(2);
|
2026-05-19 00:07:31 +07:00
|
|
|
expect(contentParts[0].image_url.url).toContain("data:image/jpeg;base64,");
|
|
|
|
|
expect(contentParts[2].image_url.url).toContain("data:image/png;base64,");
|
|
|
|
|
expect(contentParts.at(-1).text).toContain("https://example.invalid/login");
|
|
|
|
|
expect(contentParts.at(-1).text).toContain("Sebelumnya user lain bilang");
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-21 02:39:28 +07:00
|
|
|
it("skips pending discord-only images until tele upload is ready", async () => {
|
2026-05-19 00:07:31 +07:00
|
|
|
const mockResponse = {
|
|
|
|
|
choices: [
|
|
|
|
|
{
|
|
|
|
|
message: {
|
|
|
|
|
content: JSON.stringify({
|
|
|
|
|
results: [
|
|
|
|
|
{
|
|
|
|
|
message_id: "m1",
|
|
|
|
|
status: "clean",
|
|
|
|
|
flags: [],
|
|
|
|
|
score: 0.1,
|
|
|
|
|
analysis: "Image available through Discord URL fallback.",
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
}),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
global.fetch = vi.fn().mockImplementation((url: string) => {
|
|
|
|
|
if (url === "https://httpbin.org/image/png") {
|
|
|
|
|
const buffer = Buffer.from("png-bytes");
|
|
|
|
|
return Promise.resolve({
|
|
|
|
|
ok: true,
|
|
|
|
|
arrayBuffer: async () =>
|
2026-05-19 02:49:55 +07:00
|
|
|
buffer.buffer.slice(
|
|
|
|
|
buffer.byteOffset,
|
|
|
|
|
buffer.byteOffset + buffer.byteLength,
|
|
|
|
|
),
|
2026-05-19 00:07:31 +07:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Promise.resolve({
|
|
|
|
|
ok: true,
|
|
|
|
|
text: async () => JSON.stringify(mockResponse),
|
|
|
|
|
json: async () => mockResponse,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await runModerationAnalysis({
|
2026-05-19 02:49:55 +07:00
|
|
|
targets: [
|
|
|
|
|
createMessageRecord({
|
|
|
|
|
id: "m1",
|
2026-05-21 01:55:50 +07:00
|
|
|
content: "gambar belum selesai upload ke tele",
|
2026-05-19 02:49:55 +07:00
|
|
|
}),
|
|
|
|
|
],
|
2026-05-19 00:07:31 +07:00
|
|
|
contextText: "test context",
|
|
|
|
|
attachments: [
|
|
|
|
|
{
|
|
|
|
|
id: "pending-image",
|
|
|
|
|
message_id: "m1",
|
|
|
|
|
guild_id: "guild123",
|
|
|
|
|
channel_id: "channel123",
|
|
|
|
|
thread_id: null,
|
|
|
|
|
user_id: "user123",
|
|
|
|
|
filename: "pending.png",
|
|
|
|
|
size: 8090,
|
|
|
|
|
type: "image/png",
|
|
|
|
|
discord_url: "https://httpbin.org/image/png",
|
|
|
|
|
uploaded_url: null,
|
|
|
|
|
upload_status: "pending" as const,
|
|
|
|
|
upload_error: null,
|
|
|
|
|
created_at: Date.now(),
|
|
|
|
|
uploaded_at: null,
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-21 02:39:28 +07:00
|
|
|
const requestBody = JSON.parse((global.fetch as any).mock.calls[0][1].body);
|
|
|
|
|
expect((global.fetch as any).mock.calls[0][0]).toContain(
|
|
|
|
|
"/chat/completions",
|
2026-05-19 02:49:55 +07:00
|
|
|
);
|
2026-05-21 02:56:41 +07:00
|
|
|
expect(typeof requestBody.messages[0].content).toBe("string");
|
2026-05-19 00:07:31 +07:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("keeps analyzing text when an image URL returns non-OK", async () => {
|
|
|
|
|
const mockResponse = {
|
|
|
|
|
choices: [
|
|
|
|
|
{
|
|
|
|
|
message: {
|
|
|
|
|
content: JSON.stringify({
|
|
|
|
|
results: [
|
|
|
|
|
{
|
|
|
|
|
message_id: "m1",
|
|
|
|
|
status: "warn",
|
|
|
|
|
flags: ["suspicious_link"],
|
|
|
|
|
score: 0.6,
|
2026-05-19 02:49:55 +07:00
|
|
|
analysis:
|
|
|
|
|
"Image fetch failed, text still indicates suspicious link.",
|
2026-05-19 00:07:31 +07:00
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
}),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
global.fetch = vi.fn().mockImplementation((url: string) => {
|
|
|
|
|
if (url === "https://httpbin.org/image/png") {
|
|
|
|
|
return Promise.resolve({ ok: false, status: 503 });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Promise.resolve({
|
|
|
|
|
ok: true,
|
|
|
|
|
text: async () => JSON.stringify(mockResponse),
|
|
|
|
|
json: async () => mockResponse,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const result = await runModerationAnalysis({
|
|
|
|
|
targets: [
|
|
|
|
|
createMessageRecord({
|
|
|
|
|
id: "m1",
|
|
|
|
|
content: "cek bonus gratis di https://example.invalid/claim sekarang",
|
|
|
|
|
}),
|
|
|
|
|
],
|
2026-05-19 02:49:55 +07:00
|
|
|
contextText:
|
|
|
|
|
"Pesan ini dikirim berulang setelah user lain menolak klik link.",
|
2026-05-19 00:07:31 +07:00
|
|
|
attachments: [
|
|
|
|
|
{
|
|
|
|
|
id: "bad-image",
|
|
|
|
|
message_id: "m1",
|
|
|
|
|
guild_id: "guild123",
|
|
|
|
|
channel_id: "channel123",
|
|
|
|
|
thread_id: null,
|
|
|
|
|
user_id: "user123",
|
|
|
|
|
filename: "broken.png",
|
|
|
|
|
size: 8090,
|
|
|
|
|
type: "image/png",
|
|
|
|
|
discord_url: "https://httpbin.org/image/png",
|
|
|
|
|
uploaded_url: "https://httpbin.org/image/png",
|
|
|
|
|
upload_status: "uploaded" as const,
|
|
|
|
|
upload_error: null,
|
|
|
|
|
created_at: Date.now(),
|
|
|
|
|
uploaded_at: Date.now(),
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(result.results[0].status).toBe("warn");
|
|
|
|
|
|
|
|
|
|
const requestBody = JSON.parse((global.fetch as any).mock.calls[1][1].body);
|
2026-05-21 04:23:01 +07:00
|
|
|
expect(requestBody.messages[0].content).toContain(
|
2026-05-19 02:49:55 +07:00
|
|
|
"https://example.invalid/claim",
|
|
|
|
|
);
|
2026-05-19 00:07:31 +07:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("Edge Cases & Real-World Scenarios", () => {
|
|
|
|
|
it("handles single result object without results wrapper", () => {
|
|
|
|
|
const content = JSON.stringify({
|
|
|
|
|
message_id: "m1",
|
|
|
|
|
status: "clean",
|
|
|
|
|
flags: [],
|
|
|
|
|
score: 0.1,
|
|
|
|
|
analysis: "OK",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const result = parseModerationResponse(content, ["m1"]);
|
|
|
|
|
expect(result).toHaveLength(1);
|
|
|
|
|
expect(result[0].messageId).toBe("m1");
|
|
|
|
|
expect(result[0].status).toBe("clean");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("handles alternative array key 'result' instead of 'results'", () => {
|
|
|
|
|
const content = JSON.stringify({
|
|
|
|
|
result: [
|
|
|
|
|
{
|
|
|
|
|
message_id: "m1",
|
|
|
|
|
status: "flagged",
|
|
|
|
|
flags: ["spam"],
|
|
|
|
|
score: 0.8,
|
|
|
|
|
analysis: "Spam detected",
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const result = parseModerationResponse(content, ["m1"]);
|
|
|
|
|
expect(result).toHaveLength(1);
|
|
|
|
|
expect(result[0].status).toBe("flagged");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("handles alternative array key 'data' instead of 'results'", () => {
|
|
|
|
|
const content = JSON.stringify({
|
|
|
|
|
data: [
|
|
|
|
|
{
|
|
|
|
|
message_id: "m1",
|
|
|
|
|
status: "warn",
|
|
|
|
|
flags: ["hate_speech"],
|
|
|
|
|
score: 0.6,
|
|
|
|
|
analysis: "Potential hate speech",
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const result = parseModerationResponse(content, ["m1"]);
|
|
|
|
|
expect(result).toHaveLength(1);
|
|
|
|
|
expect(result[0].status).toBe("warn");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("handles alternative array key 'messages' instead of 'results'", () => {
|
|
|
|
|
const content = JSON.stringify({
|
|
|
|
|
messages: [
|
|
|
|
|
{
|
|
|
|
|
message_id: "m1",
|
|
|
|
|
status: "clean",
|
|
|
|
|
flags: [],
|
|
|
|
|
score: 0.05,
|
|
|
|
|
analysis: "No violations",
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const result = parseModerationResponse(content, ["m1"]);
|
|
|
|
|
expect(result).toHaveLength(1);
|
|
|
|
|
expect(result[0].status).toBe("clean");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("handles text with links and URLs", () => {
|
|
|
|
|
const content = JSON.stringify({
|
|
|
|
|
results: [
|
|
|
|
|
{
|
|
|
|
|
message_id: "m1",
|
|
|
|
|
status: "clean",
|
|
|
|
|
flags: [],
|
|
|
|
|
score: 0.1,
|
|
|
|
|
analysis: "Link shared, no violations",
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const result = parseModerationResponse(content, ["m1"]);
|
|
|
|
|
expect(result[0].status).toBe("clean");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("handles very long message content", () => {
|
|
|
|
|
const longContent = "a".repeat(10000);
|
|
|
|
|
const content = JSON.stringify({
|
|
|
|
|
results: [
|
|
|
|
|
{
|
|
|
|
|
message_id: "m1",
|
|
|
|
|
status: "clean",
|
|
|
|
|
flags: [],
|
|
|
|
|
score: 0.1,
|
|
|
|
|
analysis: `Analyzed ${longContent.length} character message`,
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const result = parseModerationResponse(content, ["m1"]);
|
|
|
|
|
expect(result).toHaveLength(1);
|
|
|
|
|
expect(result[0].analysis).toContain("10000");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("handles multiple messages with mixed statuses", () => {
|
|
|
|
|
const content = JSON.stringify({
|
|
|
|
|
results: [
|
|
|
|
|
{
|
|
|
|
|
message_id: "m1",
|
|
|
|
|
status: "clean",
|
|
|
|
|
flags: [],
|
|
|
|
|
score: 0.1,
|
|
|
|
|
analysis: "OK",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
message_id: "m2",
|
|
|
|
|
status: "warn",
|
|
|
|
|
flags: ["spam"],
|
|
|
|
|
score: 0.5,
|
|
|
|
|
analysis: "Potential spam",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
message_id: "m3",
|
|
|
|
|
status: "flagged",
|
|
|
|
|
flags: ["hate_speech", "abuse"],
|
|
|
|
|
score: 0.95,
|
|
|
|
|
analysis: "Severe violations",
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const result = parseModerationResponse(content, ["m1", "m2", "m3"]);
|
|
|
|
|
expect(result).toHaveLength(3);
|
|
|
|
|
expect(result[0].status).toBe("clean");
|
|
|
|
|
expect(result[1].status).toBe("warn");
|
|
|
|
|
expect(result[2].status).toBe("flagged");
|
|
|
|
|
expect(result[2].flags).toContain("hate_speech");
|
|
|
|
|
expect(result[2].flags).toContain("abuse");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("handles LLM response with thinking blocks and markdown", () => {
|
|
|
|
|
const content = `
|
|
|
|
|
<thinking>
|
|
|
|
|
Let me analyze these messages carefully.
|
|
|
|
|
Message 1 seems clean.
|
|
|
|
|
Message 2 has some concerning language.
|
|
|
|
|
</thinking>
|
|
|
|
|
|
|
|
|
|
Based on my analysis:
|
|
|
|
|
|
|
|
|
|
\`\`\`json
|
|
|
|
|
{
|
|
|
|
|
"results": [
|
|
|
|
|
{
|
|
|
|
|
"message_id": "m1",
|
|
|
|
|
"status": "clean",
|
|
|
|
|
"flags": [],
|
|
|
|
|
"score": 0.1,
|
|
|
|
|
"analysis": "No violations detected"
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"message_id": "m2",
|
|
|
|
|
"status": "warn",
|
|
|
|
|
"flags": ["inappropriate"],
|
|
|
|
|
"score": 0.6,
|
|
|
|
|
"analysis": "Contains inappropriate language"
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
\`\`\`
|
|
|
|
|
|
|
|
|
|
These are my findings.
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
const result = parseModerationResponse(content, ["m1", "m2"]);
|
|
|
|
|
expect(result).toHaveLength(2);
|
|
|
|
|
expect(result[0].status).toBe("clean");
|
|
|
|
|
expect(result[1].status).toBe("warn");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("handles response with extra fields and nested objects", () => {
|
|
|
|
|
const content = JSON.stringify({
|
|
|
|
|
results: [
|
|
|
|
|
{
|
|
|
|
|
message_id: "m1",
|
|
|
|
|
status: "flagged",
|
|
|
|
|
flags: ["violence"],
|
|
|
|
|
score: 0.9,
|
|
|
|
|
analysis: "Contains violent content",
|
|
|
|
|
metadata: {
|
|
|
|
|
confidence: 0.95,
|
|
|
|
|
model_version: "v2",
|
|
|
|
|
processing_time_ms: 150,
|
|
|
|
|
},
|
|
|
|
|
extra_field: "ignored",
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const result = parseModerationResponse(content, ["m1"]);
|
|
|
|
|
expect(result).toHaveLength(1);
|
|
|
|
|
expect(result[0].status).toBe("flagged");
|
|
|
|
|
expect(result[0].score).toBe(0.9);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("handles scientific notation in score", () => {
|
|
|
|
|
const content = JSON.stringify({
|
|
|
|
|
results: [
|
|
|
|
|
{
|
|
|
|
|
message_id: "m1",
|
|
|
|
|
status: "clean",
|
|
|
|
|
flags: [],
|
|
|
|
|
score: 1e-1,
|
|
|
|
|
analysis: "Very low risk",
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const result = parseModerationResponse(content, ["m1"]);
|
|
|
|
|
expect(result[0].score).toBe(0.1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("handles empty flags array and null analysis", () => {
|
|
|
|
|
const content = JSON.stringify({
|
|
|
|
|
results: [
|
|
|
|
|
{
|
|
|
|
|
message_id: "m1",
|
|
|
|
|
status: "clean",
|
|
|
|
|
flags: [],
|
|
|
|
|
score: 0,
|
|
|
|
|
analysis: null,
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const result = parseModerationResponse(content, ["m1"]);
|
|
|
|
|
expect(result[0].flags).toEqual([]);
|
|
|
|
|
expect(result[0].analysis).toBe("");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("handles missing optional fields with defaults", () => {
|
|
|
|
|
const content = JSON.stringify({
|
|
|
|
|
results: [
|
|
|
|
|
{
|
|
|
|
|
message_id: "m1",
|
|
|
|
|
status: "clean",
|
|
|
|
|
score: 0.1,
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const result = parseModerationResponse(content, ["m1"]);
|
|
|
|
|
expect(result[0].flags).toEqual([]);
|
|
|
|
|
expect(result[0].score).toBe(0.1);
|
|
|
|
|
expect(result[0].analysis).toBe("");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("handles Unicode and emoji in analysis text", () => {
|
|
|
|
|
const content = JSON.stringify({
|
|
|
|
|
results: [
|
|
|
|
|
{
|
|
|
|
|
message_id: "m1",
|
|
|
|
|
status: "warn",
|
|
|
|
|
flags: ["inappropriate"],
|
|
|
|
|
score: 0.7,
|
|
|
|
|
analysis: "Contains inappropriate emoji 🚫 and symbols ⚠️",
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const result = parseModerationResponse(content, ["m1"]);
|
|
|
|
|
expect(result[0].analysis).toContain("🚫");
|
|
|
|
|
expect(result[0].analysis).toContain("⚠️");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("handles response with only one array in deeply nested object", () => {
|
|
|
|
|
const content = JSON.stringify({
|
|
|
|
|
metadata: { version: 1 },
|
|
|
|
|
analysis_results: [
|
|
|
|
|
{
|
|
|
|
|
message_id: "m1",
|
|
|
|
|
status: "clean",
|
|
|
|
|
flags: [],
|
|
|
|
|
score: 0.1,
|
|
|
|
|
analysis: "OK",
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const result = parseModerationResponse(content, ["m1"]);
|
|
|
|
|
expect(result).toHaveLength(1);
|
|
|
|
|
expect(result[0].messageId).toBe("m1");
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("Real-World Text Samples", () => {
|
|
|
|
|
it("analyzes message with URL", () => {
|
|
|
|
|
const content = JSON.stringify({
|
|
|
|
|
results: [
|
|
|
|
|
{
|
|
|
|
|
message_id: "m1",
|
|
|
|
|
status: "clean",
|
|
|
|
|
flags: [],
|
|
|
|
|
score: 0.15,
|
|
|
|
|
analysis: "Message contains URL but no violations",
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const result = parseModerationResponse(content, ["m1"]);
|
|
|
|
|
expect(result[0].status).toBe("clean");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("analyzes message with mentions and hashtags", () => {
|
|
|
|
|
const content = JSON.stringify({
|
|
|
|
|
results: [
|
|
|
|
|
{
|
|
|
|
|
message_id: "m1",
|
|
|
|
|
status: "clean",
|
|
|
|
|
flags: [],
|
|
|
|
|
score: 0.05,
|
|
|
|
|
analysis: "Mentions and hashtags are acceptable",
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const result = parseModerationResponse(content, ["m1"]);
|
|
|
|
|
expect(result[0].status).toBe("clean");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("analyzes message with code snippets", () => {
|
|
|
|
|
const content = JSON.stringify({
|
|
|
|
|
results: [
|
|
|
|
|
{
|
|
|
|
|
message_id: "m1",
|
|
|
|
|
status: "clean",
|
|
|
|
|
flags: [],
|
|
|
|
|
score: 0.1,
|
|
|
|
|
analysis: "Code snippet detected, no violations",
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const result = parseModerationResponse(content, ["m1"]);
|
|
|
|
|
expect(result[0].status).toBe("clean");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("analyzes message with mixed languages", () => {
|
|
|
|
|
const content = JSON.stringify({
|
|
|
|
|
results: [
|
|
|
|
|
{
|
|
|
|
|
message_id: "m1",
|
|
|
|
|
status: "clean",
|
|
|
|
|
flags: [],
|
|
|
|
|
score: 0.1,
|
|
|
|
|
analysis: "Mixed language content analyzed successfully",
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const result = parseModerationResponse(content, ["m1"]);
|
|
|
|
|
expect(result[0].status).toBe("clean");
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("Failure Scenarios", () => {
|
|
|
|
|
it("throws on completely invalid JSON", () => {
|
|
|
|
|
expect(() =>
|
|
|
|
|
parseModerationResponse("not json at all", ["m1"]),
|
|
|
|
|
).toThrow();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("throws on empty object", () => {
|
|
|
|
|
expect(() => parseModerationResponse(JSON.stringify({}), ["m1"])).toThrow(
|
|
|
|
|
/missing.*results/i,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("throws on array without results wrapper and no message_id", () => {
|
|
|
|
|
expect(() =>
|
|
|
|
|
parseModerationResponse(
|
|
|
|
|
JSON.stringify([
|
|
|
|
|
{
|
|
|
|
|
status: "clean",
|
|
|
|
|
flags: [],
|
|
|
|
|
score: 0.1,
|
|
|
|
|
analysis: "OK",
|
|
|
|
|
},
|
|
|
|
|
]),
|
|
|
|
|
["m1"],
|
|
|
|
|
),
|
|
|
|
|
).toThrow();
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-21 02:39:28 +07:00
|
|
|
it("skips mismatched message IDs", () => {
|
|
|
|
|
const result = parseModerationResponse(
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
results: [
|
|
|
|
|
{
|
|
|
|
|
message_id: "m999",
|
|
|
|
|
status: "clean",
|
|
|
|
|
flags: [],
|
|
|
|
|
score: 0.1,
|
|
|
|
|
analysis: "OK",
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
}),
|
|
|
|
|
["m1"],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(result).toHaveLength(1);
|
|
|
|
|
expect(result[0].messageId).toBe("m1");
|
|
|
|
|
expect(result[0].analysis).toContain("incomplete");
|
2026-05-19 00:07:31 +07:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("throws on invalid status value", () => {
|
|
|
|
|
expect(() =>
|
|
|
|
|
parseModerationResponse(
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
results: [
|
|
|
|
|
{
|
|
|
|
|
message_id: "m1",
|
|
|
|
|
status: "invalid_status",
|
|
|
|
|
flags: [],
|
|
|
|
|
score: 0.5,
|
|
|
|
|
analysis: "OK",
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
}),
|
|
|
|
|
["m1"],
|
|
|
|
|
),
|
|
|
|
|
).toThrow(/invalid status/i);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("throws on non-finite score", () => {
|
|
|
|
|
const content = `{
|
|
|
|
|
"results": [
|
|
|
|
|
{
|
|
|
|
|
"message_id": "m1",
|
|
|
|
|
"status": "clean",
|
|
|
|
|
"flags": [],
|
|
|
|
|
"score": "NaN",
|
|
|
|
|
"analysis": "OK"
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
}`;
|
|
|
|
|
|
2026-05-19 02:49:55 +07:00
|
|
|
expect(() => parseModerationResponse(content, ["m1"])).toThrow(/finite/i);
|
2026-05-19 00:07:31 +07:00
|
|
|
});
|
2026-05-18 04:38:30 +07:00
|
|
|
});
|
2026-05-14 19:16:46 +07:00
|
|
|
});
|