181 lines
4.7 KiB
TypeScript
181 lines
4.7 KiB
TypeScript
import assert from "node:assert/strict"
|
|
import test from "node:test"
|
|
import { NextRequest } from "next/server"
|
|
import { POST } from "@/app/api/chat/route"
|
|
|
|
type CapturedPayload = {
|
|
model: string
|
|
messages: Array<{ role: string; content: string }>
|
|
}
|
|
|
|
const ORIGINAL_FETCH = globalThis.fetch
|
|
const ORIGINAL_XAI_KEY = process.env.XAI_API_KEY
|
|
|
|
function buildVisitor(intent: string) {
|
|
return {
|
|
name: "Taylor",
|
|
phone: "(801) 555-1000",
|
|
email: "taylor@example.com",
|
|
intent,
|
|
serviceTextConsent: true,
|
|
marketingTextConsent: false,
|
|
consentVersion: "sms-consent-v1-2026-03-26",
|
|
consentCapturedAt: "2026-03-25T00:00:00.000Z",
|
|
consentSourcePage: "/contact-us",
|
|
}
|
|
}
|
|
|
|
function buildRequest(message: string, intent = "Manuals") {
|
|
return new NextRequest("http://localhost/api/chat", {
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
pathname: "/manuals",
|
|
sessionId: "test-session",
|
|
visitor: buildVisitor(intent),
|
|
messages: [{ role: "user", content: message }],
|
|
}),
|
|
})
|
|
}
|
|
|
|
async function runChatRouteWithSpy(
|
|
message: string,
|
|
intent = "Manuals"
|
|
): Promise<{ response: Response; payload: CapturedPayload }> {
|
|
process.env.XAI_API_KEY = "test-xai-key"
|
|
let capturedPayload: CapturedPayload | null = null
|
|
|
|
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
|
|
capturedPayload = JSON.parse(String(init?.body || "{}")) as CapturedPayload
|
|
|
|
return new Response(
|
|
JSON.stringify({
|
|
choices: [
|
|
{
|
|
message: {
|
|
content: "Mock Jessica reply.",
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: {
|
|
"content-type": "application/json",
|
|
},
|
|
}
|
|
)
|
|
}) as typeof fetch
|
|
|
|
const response = await POST(buildRequest(message, intent))
|
|
|
|
assert.ok(capturedPayload)
|
|
return { response, payload: capturedPayload }
|
|
}
|
|
|
|
test.afterEach(() => {
|
|
globalThis.fetch = ORIGINAL_FETCH
|
|
|
|
if (typeof ORIGINAL_XAI_KEY === "string") {
|
|
process.env.XAI_API_KEY = ORIGINAL_XAI_KEY
|
|
} else {
|
|
delete process.env.XAI_API_KEY
|
|
}
|
|
})
|
|
|
|
test("chat route includes grounded manual context for RVV alias lookups", async () => {
|
|
const { response, payload } = await runChatRouteWithSpy(
|
|
"RVV 660 service manual"
|
|
)
|
|
|
|
assert.equal(response.status, 200)
|
|
assert.equal(
|
|
payload.messages.some(
|
|
(message) =>
|
|
message.role === "system" &&
|
|
message.content.includes("Manual knowledge context:")
|
|
),
|
|
true
|
|
)
|
|
assert.equal(
|
|
payload.messages.some(
|
|
(message) =>
|
|
message.role === "system" &&
|
|
/Royal Vendors|660/i.test(message.content)
|
|
),
|
|
true
|
|
)
|
|
})
|
|
|
|
test("chat route resolves Narco alias lookups into manual context", async () => {
|
|
const { payload } = await runChatRouteWithSpy("Narco bevmax not cooling")
|
|
|
|
const manualContext = payload.messages.find(
|
|
(message) =>
|
|
message.role === "system" &&
|
|
message.content.includes("Manual knowledge context:")
|
|
)
|
|
|
|
assert.ok(manualContext)
|
|
assert.match(manualContext.content, /Dixie-Narco|Narco/i)
|
|
})
|
|
|
|
test("chat route low-confidence manual queries instruct Jessica to ask for brand model or photo", async () => {
|
|
const { payload } = await runChatRouteWithSpy(
|
|
"manual for flibbertigibbet machine"
|
|
)
|
|
|
|
const manualContext = payload.messages.find(
|
|
(message) =>
|
|
message.role === "system" &&
|
|
message.content.includes("Manual knowledge context:")
|
|
)
|
|
|
|
assert.ok(manualContext)
|
|
assert.match(
|
|
manualContext.content,
|
|
/brand on the front|model sticker|photo\/video/i
|
|
)
|
|
})
|
|
|
|
test("chat route risky technical manual queries inject conservative safety context", async () => {
|
|
const { payload } = await runChatRouteWithSpy(
|
|
"Royal wiring diagram voltage manual",
|
|
"Repairs"
|
|
)
|
|
|
|
const systemPrompt = payload.messages[0]?.content || ""
|
|
const manualContext = payload.messages.find(
|
|
(message) =>
|
|
message.role === "system" &&
|
|
message.content.includes("Manual knowledge context:")
|
|
)
|
|
|
|
assert.match(
|
|
systemPrompt,
|
|
/Do not provide step-by-step repair procedures, wiring guidance, voltage guidance/i
|
|
)
|
|
assert.ok(manualContext)
|
|
assert.match(manualContext.content, /technical or risky/i)
|
|
})
|
|
|
|
test("chat route skips manuals retrieval for non-manual conversations", async () => {
|
|
const { payload } = await runChatRouteWithSpy(
|
|
"Can someone call me back about free placement?",
|
|
"Free Placement"
|
|
)
|
|
|
|
const systemMessages = payload.messages.filter(
|
|
(message) => message.role === "system"
|
|
)
|
|
|
|
assert.equal(systemMessages.length, 1)
|
|
assert.equal(
|
|
systemMessages.some((message) =>
|
|
message.content.includes("Manual knowledge context:")
|
|
),
|
|
false
|
|
)
|
|
})
|