Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ import { isZodError, parseRequest, validationErrorResponse } from '@/lib/api/ser
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import type { RowData } from '@/lib/table'
import type { RowData, TableSchema } from '@/lib/table'
import { deleteRow, updateRow } from '@/lib/table'
import { rowWireTranslators } from '@/app/api/table/row-wire'
import { accessError, checkAccess } from '@/app/api/table/utils'

const logger = createLogger('TableRowAPI')
Expand Down Expand Up @@ -72,12 +73,14 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Row

logger.info(`[${requestId}] Retrieved row ${rowId} from table ${tableId}`)

const wire = rowWireTranslators(authResult.authType, table.schema as TableSchema)

return NextResponse.json({
success: true,
data: {
row: {
id: row.id,
data: row.data,
data: wire.dataOut(row.data as RowData),
position: row.position,
createdAt:
row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt),
Expand Down Expand Up @@ -123,11 +126,12 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}

const wire = rowWireTranslators(authResult.authType, table.schema as TableSchema)
const updatedRow = await updateRow(
{
tableId,
rowId,
data: validated.data as RowData,
data: wire.dataIn(validated.data as RowData),
workspaceId: validated.workspaceId,
actorUserId: authResult.userId,
},
Expand All @@ -148,7 +152,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR
data: {
row: {
id: updatedRow.id,
data: updatedRow.data,
data: wire.dataOut(updatedRow.data),
position: updatedRow.position,
createdAt:
updatedRow.createdAt instanceof Date
Expand Down
220 changes: 220 additions & 0 deletions apps/sim/app/api/table/[tableId]/rows/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
/**
* @vitest-environment node
*/
import { hybridAuthMockFns } from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { TableDefinition } from '@/lib/table'

const { mockCheckAccess, mockInsertRow, mockValidateRowData, mockQueryRows } = vi.hoisted(() => ({
mockCheckAccess: vi.fn(),
Comment thread
TheodoreSpeaks marked this conversation as resolved.
mockInsertRow: vi.fn(),
mockValidateRowData: vi.fn(),
mockQueryRows: vi.fn(),
}))

vi.mock('@/app/api/table/utils', async () => {
const { NextResponse } = await import('next/server')
return {
checkAccess: mockCheckAccess,
accessError: (result: { status: number }) =>
NextResponse.json({ error: 'Access denied' }, { status: result.status }),
}
})

vi.mock('@/lib/table', async () => {
// Real column-keys translation functions; the row-wire helper under test
// imports them from this barrel.
const columnKeys = await import('@/lib/table/column-keys')
return {
...columnKeys,
insertRow: mockInsertRow,
batchInsertRows: vi.fn(),
batchUpdateRows: vi.fn(),
deleteRowsByFilter: vi.fn(),
deleteRowsByIds: vi.fn(),
updateRowsByFilter: vi.fn(),
validateBatchRows: vi.fn(),
validateRowData: mockValidateRowData,
validateRowSize: vi.fn(() => ({ valid: true })),
}
})

vi.mock('@/lib/table/service', () => ({
queryRows: mockQueryRows,
}))

vi.mock('@/lib/table/sql', () => ({
TableQueryValidationError: class TableQueryValidationError extends Error {},
}))

import { GET, POST } from '@/app/api/table/[tableId]/rows/route'

function buildTable(): TableDefinition {
return {
id: 'tbl_1',
name: 'People',
description: null,
schema: {
columns: [
{ id: 'col_aaa', name: 'Name', type: 'string' },
{ id: 'col_bbb', name: 'Age', type: 'number' },
],
},
metadata: null,
rowCount: 0,
maxRows: 100,
workspaceId: 'workspace-1',
createdBy: 'user-1',
archivedAt: null,
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
}
}

function authAs(authType: 'session' | 'internal_jwt') {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
success: true,
userId: 'user-1',
authType,
})
}

function callPost(body: Record<string, unknown>) {
const req = new NextRequest('http://localhost:3000/api/table/tbl_1/rows', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
return POST(req, { params: Promise.resolve({ tableId: 'tbl_1' }) })
}

function callGet(query: Record<string, string>) {
const params = new URLSearchParams(query)
const req = new NextRequest(`http://localhost:3000/api/table/tbl_1/rows?${params}`, {
method: 'GET',
})
return GET(req, { params: Promise.resolve({ tableId: 'tbl_1' }) })
}

describe('POST /api/table/[tableId]/rows', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCheckAccess.mockResolvedValue({ ok: true, table: buildTable() })
mockValidateRowData.mockResolvedValue({ valid: true })
mockInsertRow.mockResolvedValue({
id: 'row_1',
data: { col_aaa: 'Ada', col_bbb: 36 },
position: 1,
orderKey: 'a0',
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
})
})

it('translates name-keyed data to column ids for internal-JWT (workflow tool) callers', async () => {
authAs('internal_jwt')

const res = await callPost({
workspaceId: 'workspace-1',
data: { Name: 'Ada', Age: 36 },
})

expect(res.status).toBe(200)
expect(mockValidateRowData).toHaveBeenCalledWith(
expect.objectContaining({ rowData: { col_aaa: 'Ada', col_bbb: 36 } })
)
expect(mockInsertRow).toHaveBeenCalledWith(
expect.objectContaining({ data: { col_aaa: 'Ada', col_bbb: 36 } }),
expect.anything(),
expect.any(String)
)

const body = await res.json()
expect(body.data.row.data).toEqual({ Name: 'Ada', Age: 36 })
})

it('passes id-keyed data through untouched for session (UI) callers', async () => {
authAs('session')

const res = await callPost({
workspaceId: 'workspace-1',
data: { col_aaa: 'Ada', col_bbb: 36 },
})

expect(res.status).toBe(200)
expect(mockInsertRow).toHaveBeenCalledWith(
expect.objectContaining({ data: { col_aaa: 'Ada', col_bbb: 36 } }),
expect.anything(),
expect.any(String)
)

const body = await res.json()
expect(body.data.row.data).toEqual({ col_aaa: 'Ada', col_bbb: 36 })
})
})

describe('GET /api/table/[tableId]/rows', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCheckAccess.mockResolvedValue({ ok: true, table: buildTable() })
mockQueryRows.mockResolvedValue({
rows: [
{
id: 'row_1',
data: { col_aaa: 'Ada', col_bbb: 36 },
position: 1,
orderKey: 'a0',
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
},
],
rowCount: 1,
totalCount: 1,
limit: 100,
offset: 0,
})
})

it('translates name-keyed filter/sort and returns name-keyed rows for internal-JWT callers', async () => {
authAs('internal_jwt')

const res = await callGet({
workspaceId: 'workspace-1',
filter: JSON.stringify({ Name: { $eq: 'Ada' } }),
sort: JSON.stringify({ Age: 'desc' }),
})

expect(res.status).toBe(200)
expect(mockQueryRows).toHaveBeenCalledWith(
expect.objectContaining({ id: 'tbl_1' }),
expect.objectContaining({
filter: { col_aaa: { $eq: 'Ada' } },
sort: { col_bbb: 'desc' },
}),
expect.any(String)
)

const body = await res.json()
expect(body.data.rows[0].data).toEqual({ Name: 'Ada', Age: 36 })
})

it('passes id-keyed filter and rows through untouched for session callers', async () => {
authAs('session')

const res = await callGet({
workspaceId: 'workspace-1',
filter: JSON.stringify({ col_aaa: { $eq: 'Ada' } }),
})

expect(res.status).toBe(200)
expect(mockQueryRows).toHaveBeenCalledWith(
expect.objectContaining({ id: 'tbl_1' }),
expect.objectContaining({ filter: { col_aaa: { $eq: 'Ada' } } }),
expect.any(String)
)

const body = await res.json()
expect(body.data.rows[0].data).toEqual({ col_aaa: 'Ada', col_bbb: 36 })
})
})
Loading
Loading