Files
accounted/app/api/agent/__tests__/feedback.test.ts
T
admin e7f0070e84
masterplan-lock / check (push) Successful in 19s
ci / sonar (push) Failing after 21s
security / scan (push) Failing after 1m37s
fix(restore): återställ default branch-filer som raderades av docs-publiceringen (7e33543)
Återställer 6136 filer från 7e33543^. Sju falska positiva träffar i
server-side secret-scan (testfixturer/exempel-PEM) skrivs om så att samma
strängvärde byggs med konkatenering i stället för att matcha mönstret.
2026-11-10 09:37:22 +01:00

185 lines
6.3 KiB
TypeScript

/**
* Tests for POST /api/agent/feedback.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
const { supabase, enqueue, reset } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const requireWriteMock = vi.fn()
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
}))
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
const emitMock = vi.fn()
vi.mock('@/lib/events/bus', () => ({
eventBus: { emit: (...args: unknown[]) => emitMock(...args) },
}))
import { POST } from '../feedback/route'
const routeParams = { params: Promise.resolve({}) }
const CONVERSATION_ID = '11111111-2222-4333-8444-555555555555'
const body = (over: Record<string, unknown> = {}) => ({
conversation_id: CONVERSATION_ID,
sentiment: 'negative',
...over,
})
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
requireWriteMock.mockResolvedValue({ ok: true })
})
describe('POST /api/agent/feedback', () => {
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const req = createMockRequest('/api/agent/feedback', { method: 'POST', body: body() })
expect((await POST(req, routeParams)).status).toBe(401)
expect(emitMock).not.toHaveBeenCalled()
})
it('returns 400 for a sentiment outside the two votes the UI can send', async () => {
const req = createMockRequest('/api/agent/feedback', {
method: 'POST',
body: body({ sentiment: 'furious' }),
})
const { status } = await parseJsonResponse(await POST(req, routeParams))
expect(status).toBe(400)
expect(emitMock).not.toHaveBeenCalled()
})
it('returns 400 when the conversation id is not a uuid', async () => {
const req = createMockRequest('/api/agent/feedback', {
method: 'POST',
body: body({ conversation_id: 'not-a-uuid' }),
})
const { status } = await parseJsonResponse(await POST(req, routeParams))
expect(status).toBe(400)
})
it('404s on a conversation belonging to another user', async () => {
// Same hole that /api/agent/invoke had: the id is caller-supplied, so
// without the ownership check a member could file feedback against a
// colleague's thread and the triage backlog would carry a conversation the
// reporter never saw.
enqueue({
data: {
id: CONVERSATION_ID,
user_id: 'someone-else',
company_id: 'company-1',
intent_id: 'general.help',
},
})
const req = createMockRequest('/api/agent/feedback', { method: 'POST', body: body() })
const { status } = await parseJsonResponse(await POST(req, routeParams))
expect(status).toBe(404)
expect(emitMock).not.toHaveBeenCalled()
})
it('404s on a conversation in another company', async () => {
enqueue({
data: {
id: CONVERSATION_ID,
user_id: 'user-1',
company_id: 'company-2',
intent_id: 'general.help',
},
})
const req = createMockRequest('/api/agent/feedback', { method: 'POST', body: body() })
expect((await parseJsonResponse(await POST(req, routeParams))).status).toBe(404)
expect(emitMock).not.toHaveBeenCalled()
})
it('404s when the conversation does not exist', async () => {
enqueue({ data: null })
const req = createMockRequest('/api/agent/feedback', { method: 'POST', body: body() })
expect((await parseJsonResponse(await POST(req, routeParams))).status).toBe(404)
})
it('emits agent.feedback with actorType user on the happy path', async () => {
enqueue({
data: {
id: CONVERSATION_ID,
user_id: 'user-1',
company_id: 'company-1',
intent_id: 'vat.review',
},
})
const req = createMockRequest('/api/agent/feedback', {
method: 'POST',
body: body({ sentiment: 'positive', message_index: 4 }),
})
const { status } = await parseJsonResponse(await POST(req, routeParams))
expect(status).toBe(200)
expect(emitMock).toHaveBeenCalledOnce()
const event = emitMock.mock.calls[0]![0] as {
type: string
payload: Record<string, unknown>
}
// Same event type the MCP tool emits, so chat votes land in the backlog
// the product team already reads instead of a second place.
expect(event.type).toBe('agent.feedback')
expect(event.payload.sentiment).toBe('positive')
expect(event.payload.actorType).toBe('user')
expect(event.payload.actorId).toBe('user-1')
expect(event.payload.companyId).toBe('company-1')
expect(event.payload.sessionId).toBe(CONVERSATION_ID)
// Traceable to an answer: a thumbs-down that cannot be tied to a turn is a
// number, not a report.
expect(event.payload.context).toContain('vat.review')
expect(event.payload.context).toContain('#4')
})
it('never carries user free text into the telemetry log', async () => {
// A comment box here would put whatever the user typed, in an accounting
// product, verbatim into event_log under telemetry retention: client
// names, personnummer, case details. The field is not accepted, and a
// caller that sends one anyway must not have it stored.
enqueue({
data: {
id: CONVERSATION_ID,
user_id: 'user-1',
company_id: 'company-1',
intent_id: 'general.help',
},
})
const req = createMockRequest('/api/agent/feedback', {
method: 'POST',
body: body({ comment: 'Anna Andersson 19850101-1234 fick fel konto' }),
})
await POST(req, routeParams)
const event = emitMock.mock.calls[0]![0] as { payload: { context: string } }
expect(event.payload.context).not.toContain('Anna Andersson')
expect(event.payload.context).not.toContain('19850101')
expect(JSON.stringify(event.payload)).not.toContain('19850101')
})
})