Skip to content
Original file line number Diff line number Diff line change
@@ -1,101 +1,102 @@
/**
* @vitest-environment node
*
* The enrichment-detail surface after moving onto the shared internal route
* builder. It previously queried the database from the adapter; the assertions
* below are the same wire outcomes, now with the use case as the seam.
*/
import { createTableDefinition, hybridAuthMockFns } from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { EnrichmentRunDetail } from '@/lib/table'

const { mockCheckAccess, mockLoadEnrichmentDetail } = vi.hoisted(() => ({
mockCheckAccess: vi.fn(),
mockLoadEnrichmentDetail: vi.fn(),
const { mocks } = vi.hoisted(() => ({
mocks: { readDetail: vi.fn(), authenticate: vi.fn() },
}))

vi.mock('@/lib/table/rows/executions', () => ({
loadEnrichmentDetail: mockLoadEnrichmentDetail,
}))
vi.mock('@/app/api/table/utils', async () => {
const { NextResponse } = await import('next/server')
vi.mock('@/lib/table/application/rows', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/table/application/rows')>()
return {
checkAccess: mockCheckAccess,
accessError: (result: { status: number }) =>
NextResponse.json({ error: 'denied' }, { status: result.status }),
...actual,
readTableRowEnrichmentDetail: {
operation: { id: 'tables.rows.read' },
execute: mocks.readDetail,
},
}
})

vi.mock('@/lib/table/api', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/table/api')>()
return { ...actual, internalTableSessionOrExecutorAuth: { authenticate: mocks.authenticate } }
})

import { InternalUnauthenticatedError } from '@/lib/api/server/routes'
import { NoWorkspaceAccessError } from '@/lib/core/application'
import { GET } from '@/app/api/table/[tableId]/rows/[rowId]/enrichment/[groupId]/route'

function makeRequest(tableId = 'tbl_1', rowId = 'row_1', groupId = 'grp_1') {
const req = new NextRequest(
`http://localhost:3000/api/table/${tableId}/rows/${rowId}/enrichment/${groupId}`
)
return GET(req, { params: Promise.resolve({ tableId, rowId, groupId }) })
const TABLE = { id: 'tbl_1', workspaceId: 'workspace-1', schema: { columns: [] } }
const DETAIL = { providers: [{ id: 'clearbit', status: 'hit' }], costUsd: 0.01 }

function routeContext() {
return {
params: Promise.resolve({ tableId: 'tbl_1', rowId: 'row_1', groupId: 'grp_1' }),
}
}

const detail: EnrichmentRunDetail = {
startedAt: '2026-06-18T00:00:00.000Z',
completedAt: '2026-06-18T00:00:01.000Z',
durationMs: 1000,
totalCost: 0.05,
matchedProvider: 'hunter',
aborted: false,
providers: [
{
id: 'hunter',
label: 'Hunter',
toolId: 'hunter_find_email',
status: 'matched',
cost: 0.05,
durationMs: 1000,
error: null,
},
],
function request() {
return new NextRequest('http://localhost/api/table/tbl_1/rows/row_1/enrichment/grp_1', {
method: 'GET',
})
}

beforeEach(() => {
vi.clearAllMocks()
mocks.authenticate.mockResolvedValue({
kind: 'session',
userId: 'user-1',
sessionId: 'session-1',
})
mocks.readDetail.mockResolvedValue({ table: TABLE, detail: DETAIL })
})

describe('GET /api/table/[tableId]/rows/[rowId]/enrichment/[groupId]', () => {
beforeEach(() => {
vi.clearAllMocks()
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
success: true,
userId: 'user-1',
authType: 'session',
})
mockCheckAccess.mockResolvedValue({ ok: true, table: createTableDefinition({ rowCount: 1 }) })
it('returns 401 when the caller is not authenticated', async () => {
mocks.authenticate.mockRejectedValue(new InternalUnauthenticatedError())

const response = await GET(request(), routeContext())

expect(response.status).toBe(401)
expect(mocks.readDetail).not.toHaveBeenCalled()
})

it('returns the enrichment detail', async () => {
mockLoadEnrichmentDetail.mockResolvedValue(detail)
const res = await makeRequest()
expect(res.status).toBe(200)
const json = await res.json()
expect(json).toEqual({ success: true, data: { detail } })
expect(mockLoadEnrichmentDetail).toHaveBeenCalledWith(
expect.anything(),
'tbl_1',
'row_1',
'grp_1'
)
const response = await GET(request(), routeContext())

expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({ success: true, data: { detail: DETAIL } })
})

it('returns null when there is no recorded run', async () => {
mockLoadEnrichmentDetail.mockResolvedValue(null)
const res = await makeRequest()
expect(res.status).toBe(200)
const json = await res.json()
expect(json).toEqual({ success: true, data: { detail: null } })
mocks.readDetail.mockResolvedValue({ table: TABLE, detail: null })

const body = await (await GET(request(), routeContext())).json()

expect(body).toEqual({ success: true, data: { detail: null } })
})

it('401s when unauthenticated', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ success: false })
const res = await makeRequest()
expect(res.status).toBe(401)
expect(mockLoadEnrichmentDetail).not.toHaveBeenCalled()
it('passes the row and group through to the use case', async () => {
await GET(request(), routeContext())

expect(mocks.readDetail.mock.calls[0][0].input).toMatchObject({
tableId: 'tbl_1',
rowId: 'row_1',
groupId: 'grp_1',
})
})

it('denies when access check fails', async () => {
mockCheckAccess.mockResolvedValue({ ok: false, status: 403 })
const res = await makeRequest()
expect(res.status).toBe(403)
expect(mockLoadEnrichmentDetail).not.toHaveBeenCalled()
it('conceals a cross-tenant table rather than confirming it exists', async () => {
mocks.readDetail.mockRejectedValue(new NoWorkspaceAccessError())

const response = await GET(request(), routeContext())

expect(response.status).toBe(404)
})
})
Original file line number Diff line number Diff line change
@@ -1,51 +1,32 @@
import { db } from '@sim/db'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { getEnrichmentDetailContract } from '@/lib/api/contracts/tables'
import { parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { loadEnrichmentDetail } from '@/lib/table/rows/executions'
import { accessError, checkAccess } from '@/app/api/table/utils'
import { defineInternalJsonRoute, internalRateLimits } from '@/lib/api/server/routes'
import { internalTableSessionOrExecutorAuth } from '@/lib/table/api'
import { internalTableRowsErrorPolicy } from '@/lib/table/api/row-route-policies'
import { tableOperations } from '@/lib/table/application/operations'
import { readTableRowEnrichmentDetail } from '@/lib/table/application/rows'

const logger = createLogger('EnrichmentDetailAPI')

interface RouteParams {
params: Promise<{ tableId: string; rowId: string; groupId: string }>
}
export const dynamic = 'force-dynamic'

/**
* GET /api/table/[tableId]/rows/[rowId]/enrichment/[groupId]
*
* Returns the enrichment cascade breakdown (provider outcomes, cost, timing)
* for one enrichment cell. Read on demand by the enrichment details panel —
* this data is deliberately kept off the hot grid read. Returns `null` for
* cells with no recorded run or runs that predate the feature.
* The enrichment cascade breakdown — provider outcomes, cost, timing — for one
* enrichment cell. Read on demand by the details panel; this data is
* deliberately kept off the hot grid read.
*/
export const GET = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => {
const requestId = generateRequestId()

const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}

const parsed = await parseRequest(getEnrichmentDetailContract, request, { params })
if (!parsed.success) return parsed.response
const { tableId, rowId, groupId } = parsed.data.params

const result = await checkAccess(tableId, authResult.userId, 'read')
if (!result.ok) return accessError(result, requestId, tableId)

const detail = await loadEnrichmentDetail(db, tableId, rowId, groupId)

logger.info(`[${requestId}] Loaded enrichment detail`, {
tableId,
rowId,
groupId,
hasDetail: detail !== null,
})

return NextResponse.json({ success: true, data: { detail } })
export const GET = defineInternalJsonRoute({
contract: getEnrichmentDetailContract,
operation: tableOperations.readRow,
auth: internalTableSessionOrExecutorAuth,
rateLimit: internalRateLimits.none({
reason: 'Preserve existing internal enrichment-detail behavior',
}),
errorPolicy: internalTableRowsErrorPolicy,
mapInput: ({ params }) => ({
tableId: params.tableId,
rowId: params.rowId,
groupId: params.groupId,
}),
useCase: readTableRowEnrichmentDetail,
present: ({ detail }) => ({ success: true as const, data: { detail } }),
})
Loading
Loading