mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
refactor(API): Migrate remaining data-tables row endpoints to PublicApiController (no-changelog) (#39985)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
c5658ba4bf
commit
1b294221b5
|
|
@ -1,8 +1,10 @@
|
|||
import '../../openapi-extend';
|
||||
|
||||
import { jsonParse } from 'n8n-workflow';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
clearDataTableRowsFieldDocs,
|
||||
createDataTableColumnFieldDocs,
|
||||
createDataTableFieldDocs,
|
||||
createDataTableRowsFieldDocs,
|
||||
|
|
@ -11,13 +13,16 @@ import {
|
|||
dataTableListFieldDocs,
|
||||
dataTableRowFieldDocs,
|
||||
dataTableRowListFieldDocs,
|
||||
deleteDataTableRowsQueryDocs,
|
||||
updateDataTableColumnFieldDocs,
|
||||
updateDataTableFieldDocs,
|
||||
updateDataTableRowFieldDocs,
|
||||
upsertDataTableRowFieldDocs,
|
||||
} from './data-table-public.openapi';
|
||||
import { booleanFromString } from '../../schemas/boolean-from-string';
|
||||
import {
|
||||
FilterConditionSchema,
|
||||
dataTableFilterRecordSchema,
|
||||
dataTableFilterSchema,
|
||||
dataTableFilterTypeSchema,
|
||||
} from '../../schemas/data-table-filter.schema';
|
||||
import {
|
||||
|
|
@ -173,15 +178,26 @@ export class InsertDataTableRowsResponsePublicDto {
|
|||
}
|
||||
}
|
||||
|
||||
const publicUpsertFilterSchema = z
|
||||
.object({
|
||||
type: dataTableFilterTypeSchema.default('and'),
|
||||
filters: z.array(dataTableFilterRecordSchema.extend({ condition: FilterConditionSchema })),
|
||||
})
|
||||
.refine((filter) => filter.filters.length > 0, { message: 'filter must not be empty' });
|
||||
const legacyFilterConditionSchema = z.union([
|
||||
z.literal('eq'),
|
||||
z.literal('neq'),
|
||||
z.literal('like'),
|
||||
z.literal('ilike'),
|
||||
z.literal('gt'),
|
||||
z.literal('gte'),
|
||||
z.literal('lt'),
|
||||
z.literal('lte'),
|
||||
]);
|
||||
|
||||
const publicUpsertUpdateFilterSchema = z.object({
|
||||
type: dataTableFilterTypeSchema.default('and'),
|
||||
filters: z
|
||||
.array(dataTableFilterRecordSchema.extend({ condition: legacyFilterConditionSchema }))
|
||||
.min(1, 'filter must not be empty'),
|
||||
});
|
||||
|
||||
export class UpsertDataTableRowPublicDto extends Z.class({
|
||||
filter: publicUpsertFilterSchema.openapi(upsertDataTableRowFieldDocs.filter),
|
||||
filter: publicUpsertUpdateFilterSchema.openapi(upsertDataTableRowFieldDocs.filter),
|
||||
data: z
|
||||
.record(dataTableColumnNameSchema, dataTableColumnValueSchema)
|
||||
.refine((obj) => Object.keys(obj).length > 0, { message: 'data must not be empty' })
|
||||
|
|
@ -222,3 +238,108 @@ export class UpsertDataTableRowResponsePublicDto {
|
|||
return upsertDataTableRowResponseSchema.safeParse(data);
|
||||
}
|
||||
}
|
||||
|
||||
export class UpdateDataTableRowPublicDto extends Z.class({
|
||||
filter: publicUpsertUpdateFilterSchema.openapi(updateDataTableRowFieldDocs.filter),
|
||||
data: z
|
||||
.record(dataTableColumnNameSchema, dataTableColumnValueSchema)
|
||||
.refine((obj) => Object.keys(obj).length > 0, { message: 'data must not be empty' })
|
||||
.openapi(updateDataTableRowFieldDocs.data),
|
||||
returnData: z.boolean().optional().default(false).openapi(updateDataTableRowFieldDocs.returnData),
|
||||
dryRun: z.boolean().optional().default(false).openapi(updateDataTableRowFieldDocs.dryRun),
|
||||
}) {}
|
||||
|
||||
const updateDataTableRowResponseSchema = z.union([
|
||||
z.boolean().openapi({ description: 'Returned when returnData is false and dryRun is false' }),
|
||||
z.array(dataTableRowWithStatePublicSchema).openapi({
|
||||
description: 'Returned when dryRun is true: one entry per row per before/after state',
|
||||
}),
|
||||
z
|
||||
.array(dataTableRowPublicSchema)
|
||||
.openapi({ description: 'Returned when returnData is true and dryRun is false' }),
|
||||
]);
|
||||
|
||||
export class UpdateDataTableRowResponsePublicDto {
|
||||
static schema = updateDataTableRowResponseSchema;
|
||||
|
||||
static parse(data: unknown) {
|
||||
return updateDataTableRowResponseSchema.parse(data);
|
||||
}
|
||||
|
||||
static safeParse(data: unknown) {
|
||||
return updateDataTableRowResponseSchema.safeParse(data);
|
||||
}
|
||||
}
|
||||
|
||||
const publicRowFilterQueryValidator = z.string().transform((val, ctx) => {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = jsonParse(val);
|
||||
} catch {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Invalid filter format',
|
||||
path: ['filter'],
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
|
||||
const result = dataTableFilterSchema.safeParse(parsed);
|
||||
if (!result.success) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Invalid filter fields',
|
||||
path: ['filter'],
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
|
||||
if (result.data.filters.length === 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'At least one filter condition is required for delete operations',
|
||||
path: ['filter'],
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
|
||||
return result.data;
|
||||
});
|
||||
|
||||
export class DeleteDataTableRowsPublicQueryDto extends Z.class({
|
||||
filter: publicRowFilterQueryValidator.openapi(deleteDataTableRowsQueryDocs.filter),
|
||||
returnData: booleanFromString
|
||||
.optional()
|
||||
.default('false')
|
||||
.openapi(deleteDataTableRowsQueryDocs.returnData),
|
||||
dryRun: booleanFromString
|
||||
.optional()
|
||||
.default('false')
|
||||
.openapi(deleteDataTableRowsQueryDocs.dryRun),
|
||||
}) {}
|
||||
|
||||
const deleteDataTableRowsResponseSchema = z.union([
|
||||
z.boolean().openapi({ description: 'Returned when returnData is false and dryRun is false' }),
|
||||
z.array(dataTableRowWithStatePublicSchema).openapi({
|
||||
description: 'Returned when dryRun is true: one entry per row per before/after state',
|
||||
}),
|
||||
z
|
||||
.array(dataTableRowPublicSchema)
|
||||
.openapi({ description: 'Returned when returnData is true and dryRun is false' }),
|
||||
]);
|
||||
|
||||
export class DeleteDataTableRowsResponsePublicDto {
|
||||
static schema = deleteDataTableRowsResponseSchema;
|
||||
|
||||
static parse(data: unknown) {
|
||||
return deleteDataTableRowsResponseSchema.parse(data);
|
||||
}
|
||||
|
||||
static safeParse(data: unknown) {
|
||||
return deleteDataTableRowsResponseSchema.safeParse(data);
|
||||
}
|
||||
}
|
||||
|
||||
export class ClearDataTableRowsResponsePublicDto extends Z.class({
|
||||
deletedCount: z.number().int().openapi(clearDataTableRowsFieldDocs.deletedCount),
|
||||
}) {}
|
||||
|
|
|
|||
|
|
@ -155,8 +155,43 @@ export const upsertDataTableRowFieldDocs = {
|
|||
data: { description: 'Column values for the row' },
|
||||
returnData: {
|
||||
description:
|
||||
'Set to true to return the upserted row(s) matched by the filter. Set to false to return ' +
|
||||
'true on success.',
|
||||
'Set to true to return the upserted row(s): the matching rows if updated, or the new ' +
|
||||
'row if one was inserted. Set to false to return true on success.',
|
||||
},
|
||||
dryRun: { description: 'Set to true to preview the change without saving it.' },
|
||||
} as const satisfies Record<string, ZodOpenAPIMetadata>;
|
||||
|
||||
export const updateDataTableRowFieldDocs = {
|
||||
filter: { description: 'Filter conditions to match rows for update.' },
|
||||
data: { description: 'Column values to update' },
|
||||
returnData: {
|
||||
description:
|
||||
'Set to true to return the updated row(s). Set to false to return true on success.',
|
||||
},
|
||||
dryRun: { description: 'Set to true to preview the change without saving it.' },
|
||||
} as const satisfies Record<string, ZodOpenAPIMetadata>;
|
||||
|
||||
export const deleteDataTableRowsQueryDocs = {
|
||||
filter: {
|
||||
type: 'string',
|
||||
format: 'jsonString',
|
||||
param: {
|
||||
description:
|
||||
'JSON string of filter conditions. Required to prevent accidental deletion of all data.',
|
||||
example:
|
||||
'{"type":"and","filters":[{"columnName":"status","condition":"eq","value":"archived"}]}',
|
||||
},
|
||||
},
|
||||
returnData: {
|
||||
param: { description: 'If true, return the deleted rows. If false, return true on success.' },
|
||||
},
|
||||
dryRun: {
|
||||
param: {
|
||||
description: 'If true, preview which rows would be deleted without actually deleting them.',
|
||||
},
|
||||
},
|
||||
} as const satisfies Record<string, ZodOpenAPIMetadata>;
|
||||
|
||||
export const clearDataTableRowsFieldDocs = {
|
||||
deletedCount: { description: 'The number of rows that were deleted' },
|
||||
} as const satisfies Record<string, ZodOpenAPIMetadata>;
|
||||
|
|
|
|||
|
|
@ -426,6 +426,7 @@ export { TestOidcConfigResponseDto } from './oidc/test-oidc-config-response.dto'
|
|||
export { CreateDataTableDto } from './data-table/create-data-table.dto';
|
||||
export { UpdateDataTableDto } from './data-table/update-data-table.dto';
|
||||
export {
|
||||
ClearDataTableRowsResponsePublicDto,
|
||||
CreateDataTableColumnPublicDto,
|
||||
CreateDataTablePublicDto,
|
||||
CreateDataTableRowsPublicDto,
|
||||
|
|
@ -434,9 +435,13 @@ export {
|
|||
DataTableListPublicDto,
|
||||
DataTablePublicDto,
|
||||
DataTableRowListPublicDto,
|
||||
DeleteDataTableRowsPublicQueryDto,
|
||||
DeleteDataTableRowsResponsePublicDto,
|
||||
InsertDataTableRowsResponsePublicDto,
|
||||
UpdateDataTableColumnPublicDto,
|
||||
UpdateDataTablePublicDto,
|
||||
UpdateDataTableRowPublicDto,
|
||||
UpdateDataTableRowResponsePublicDto,
|
||||
UpsertDataTableRowPublicDto,
|
||||
UpsertDataTableRowResponsePublicDto,
|
||||
dataTablePublicSchema,
|
||||
|
|
|
|||
|
|
@ -137,7 +137,6 @@ export default defineConfig(
|
|||
// `@PublicApiController` classes (API-70). NEVER add to this list — a new tuple handler
|
||||
// must fail CI. Entries are removed as each handler becomes a controller.
|
||||
files: [
|
||||
'./src/public-api/v1/handlers/data-tables/data-tables.rows.handler.ts',
|
||||
'./src/public-api/v1/handlers/ldap/ldap.handler.ts',
|
||||
'./src/public-api/v1/handlers/log-streaming/log-streaming.handler.ts',
|
||||
'./src/public-api/v1/handlers/n8n-packages/n8n-packages.handler.ts',
|
||||
|
|
|
|||
|
|
@ -130,7 +130,6 @@ export default defineConfig({
|
|||
// `@PublicApiController` classes (API-70). NEVER add to this list — a new tuple handler
|
||||
// must fail CI. Entries are removed as each handler becomes a controller.
|
||||
files: [
|
||||
'./src/public-api/v1/handlers/data-tables/data-tables.rows.handler.ts',
|
||||
'./src/public-api/v1/handlers/evaluations/evaluations.handler.ts',
|
||||
'./src/public-api/v1/handlers/ldap/ldap.handler.ts',
|
||||
'./src/public-api/v1/handlers/log-streaming/log-streaming.handler.ts',
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import type {
|
||||
UpdateDataTableRowDto,
|
||||
PublicCreateDestination,
|
||||
UpdateOtelSettingsDto,
|
||||
TestOtelTraceDto,
|
||||
|
|
@ -116,27 +115,6 @@ export interface IJsonSchema {
|
|||
required: string[];
|
||||
}
|
||||
|
||||
// ----------------------------------
|
||||
// /data-tables
|
||||
// ----------------------------------
|
||||
|
||||
export declare namespace DataTableRequest {
|
||||
type UpdateRows = AuthenticatedRequest<{ dataTableId: string }, {}, UpdateDataTableRowDto, {}>;
|
||||
|
||||
type Clear = AuthenticatedRequest<{ dataTableId: string }, {}, {}, {}>;
|
||||
|
||||
type DeleteRows = AuthenticatedRequest<
|
||||
{ dataTableId: string },
|
||||
{},
|
||||
{},
|
||||
{
|
||||
filter?: string;
|
||||
returnData?: string | boolean;
|
||||
dryRun?: string | boolean;
|
||||
}
|
||||
>;
|
||||
}
|
||||
|
||||
// ----------------------------------
|
||||
// /community-packages
|
||||
// ----------------------------------
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import {
|
||||
ClearDataTableRowsResponsePublicDto,
|
||||
columnIdParamSchema,
|
||||
CreateDataTableColumnPublicDto,
|
||||
CreateDataTablePublicDto,
|
||||
|
|
@ -8,11 +9,15 @@ import {
|
|||
DataTableListPublicDto,
|
||||
DataTablePublicDto,
|
||||
DataTableRowListPublicDto,
|
||||
DeleteDataTableRowsPublicQueryDto,
|
||||
DeleteDataTableRowsResponsePublicDto,
|
||||
InsertDataTableRowsResponsePublicDto,
|
||||
PublicApiListDataTableQueryDto,
|
||||
PublicApiListDataTableRowsQueryDto,
|
||||
UpdateDataTableColumnPublicDto,
|
||||
UpdateDataTablePublicDto,
|
||||
UpdateDataTableRowPublicDto,
|
||||
UpdateDataTableRowResponsePublicDto,
|
||||
UpsertDataTableRowPublicDto,
|
||||
UpsertDataTableRowResponsePublicDto,
|
||||
dataTableIdParamSchema,
|
||||
|
|
@ -147,6 +152,15 @@ const isRowWithState = (
|
|||
row: DataTableRowReturn | DataTableRowReturnWithState,
|
||||
): row is DataTableRowReturnWithState => Object.hasOwn(row, 'dryRunState');
|
||||
|
||||
const toMutateRowsResponsePublic = (
|
||||
result: boolean | DataTableRowReturn[] | DataTableRowReturnWithState[],
|
||||
) =>
|
||||
typeof result === 'boolean'
|
||||
? result
|
||||
: result.map((row) =>
|
||||
isRowWithState(row) ? toDataTableRowWithStatePublic(row) : toDataTableRowPublic(row),
|
||||
);
|
||||
|
||||
@PublicApiController('/data-tables')
|
||||
export class DataTablesPublicController {
|
||||
constructor(
|
||||
|
|
@ -505,11 +519,103 @@ export class DataTablesPublicController {
|
|||
dryRun,
|
||||
);
|
||||
|
||||
return typeof result === 'boolean'
|
||||
? result
|
||||
: result.map((row) =>
|
||||
isRowWithState(row) ? toDataTableRowWithStatePublic(row) : toDataTableRowPublic(row),
|
||||
);
|
||||
return toMutateRowsResponsePublic(result);
|
||||
} catch (error) {
|
||||
return handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
@Patch('/:dataTableId/rows/update')
|
||||
@ApiKeyScope('dataTableRow:update')
|
||||
@ProjectScope('dataTable:writeRow')
|
||||
@ApiSummary('Update rows in a data table')
|
||||
@ApiDescription('Update rows matching filter conditions in a data table.')
|
||||
@ApiTags(tags)
|
||||
@ApiResponse(200, UpdateDataTableRowResponsePublicDto)
|
||||
@ApiErrorResponse(404)
|
||||
async updateDataTableRows(
|
||||
req: AuthenticatedRequest,
|
||||
_res: Response,
|
||||
@Param('dataTableId', dataTableIdParamSchema) dataTableId: string,
|
||||
@Body body: UpdateDataTableRowPublicDto,
|
||||
) {
|
||||
const { filter, data, returnData, dryRun } = body;
|
||||
|
||||
try {
|
||||
await assertRowReadAccessIfReturningRows(req.user, dataTableId, { dryRun, returnData });
|
||||
|
||||
const projectId = await this.dataTableService.getProjectIdForDataTable(dataTableId);
|
||||
const result = await this.dataTableService.updateRows(
|
||||
dataTableId,
|
||||
projectId,
|
||||
{ filter, data },
|
||||
returnData,
|
||||
dryRun,
|
||||
);
|
||||
|
||||
return toMutateRowsResponsePublic(result);
|
||||
} catch (error) {
|
||||
return handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
@Delete('/:dataTableId/rows/clear')
|
||||
@ApiKeyScope('dataTableRow:delete')
|
||||
@ProjectScope('dataTable:writeRow')
|
||||
@ApiSummary('Clear all rows from a data table')
|
||||
@ApiDescription(
|
||||
'Permanently delete all rows from a data table. The table structure is retained. This ' +
|
||||
'action cannot be undone.',
|
||||
)
|
||||
@ApiTags(tags)
|
||||
@ApiResponse(200, ClearDataTableRowsResponsePublicDto)
|
||||
@ApiErrorResponse(404)
|
||||
async clearDataTableRows(
|
||||
_req: AuthenticatedRequest,
|
||||
_res: Response,
|
||||
@Param('dataTableId', dataTableIdParamSchema) dataTableId: string,
|
||||
): Promise<ClearDataTableRowsResponsePublicDto> {
|
||||
try {
|
||||
const projectId = await this.dataTableService.getProjectIdForDataTable(dataTableId);
|
||||
|
||||
return await this.dataTableService.clearRows(dataTableId, projectId);
|
||||
} catch (error) {
|
||||
return handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
@Delete('/:dataTableId/rows/delete')
|
||||
@ApiKeyScope('dataTableRow:delete')
|
||||
@ProjectScope('dataTable:writeRow')
|
||||
@ApiSummary('Delete rows from a data table')
|
||||
@ApiDescription(
|
||||
'Delete rows matching filter conditions from a data table. Filter is required to prevent ' +
|
||||
'accidental deletion of all data.',
|
||||
)
|
||||
@ApiTags(tags)
|
||||
@ApiResponse(200, DeleteDataTableRowsResponsePublicDto)
|
||||
@ApiErrorResponse(404)
|
||||
async deleteDataTableRows(
|
||||
req: AuthenticatedRequest,
|
||||
_res: Response,
|
||||
@Param('dataTableId', dataTableIdParamSchema) dataTableId: string,
|
||||
@Query query: DeleteDataTableRowsPublicQueryDto,
|
||||
) {
|
||||
const { filter, returnData, dryRun } = query;
|
||||
|
||||
try {
|
||||
await assertRowReadAccessIfReturningRows(req.user, dataTableId, { dryRun, returnData });
|
||||
|
||||
const projectId = await this.dataTableService.getProjectIdForDataTable(dataTableId);
|
||||
const result = await this.dataTableService.deleteRows(
|
||||
dataTableId,
|
||||
projectId,
|
||||
{ filter },
|
||||
returnData,
|
||||
dryRun,
|
||||
);
|
||||
|
||||
return toMutateRowsResponsePublic(result);
|
||||
} catch (error) {
|
||||
return handleError(error);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,392 +0,0 @@
|
|||
import { mockInstance } from '@n8n/backend-test-utils';
|
||||
import { Container } from '@n8n/di';
|
||||
import { GLOBAL_MEMBER_SCOPES, type Scope } from '@n8n/permissions';
|
||||
import type { Response } from 'express';
|
||||
import type { Mocked } from 'vitest';
|
||||
|
||||
import { BadRequestError, NotFoundError } from '@n8n/errors';
|
||||
import { DataTableAggregateService } from '@/modules/data-table/data-table-aggregate.service';
|
||||
import { DataTableService } from '@/modules/data-table/data-table.service';
|
||||
import { DataTableNotFoundError } from '@/modules/data-table/errors/data-table-not-found.error';
|
||||
import type { DataTableRequest } from '@/public-api/types';
|
||||
import * as middlewares from '@/public-api/v1/shared/middlewares/global.middleware';
|
||||
|
||||
// Mock middleware before requiring handler
|
||||
const mockMiddleware = vi.fn(async (_req, _res, next) => next()) as any;
|
||||
vi.spyOn(middlewares, 'publicApiScope').mockReturnValue(mockMiddleware);
|
||||
vi.spyOn(middlewares, 'projectScope').mockReturnValue(mockMiddleware);
|
||||
|
||||
// Loaded after the middleware spies above are installed; typed loosely so the
|
||||
// suite can invoke individual route entries by index.
|
||||
let handler: Record<string, Array<(...args: unknown[]) => unknown>>;
|
||||
|
||||
beforeAll(async () => {
|
||||
handler = (await import('../data-tables.rows.handler.js')) as unknown as typeof handler;
|
||||
});
|
||||
|
||||
describe('DataTable Handler', () => {
|
||||
let mockDataTableService: Mocked<DataTableService>;
|
||||
let mockDataTableAggregateService: Mocked<DataTableAggregateService>;
|
||||
let mockResponse: Partial<Response>;
|
||||
|
||||
const projectId = 'test-project-id';
|
||||
const dataTableId = 'test-data-table-id';
|
||||
const userId = 'test-user-id';
|
||||
|
||||
const makeUser = (scopeSlugs: Scope[] = GLOBAL_MEMBER_SCOPES) => ({
|
||||
id: userId,
|
||||
role: { slug: 'global:member', scopes: scopeSlugs.map((slug) => ({ slug })) },
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockDataTableService = mockInstance(DataTableService);
|
||||
mockDataTableAggregateService = mockInstance(DataTableAggregateService);
|
||||
|
||||
vi.spyOn(Container, 'get').mockImplementation((serviceClass) => {
|
||||
if (serviceClass === DataTableService) {
|
||||
return mockDataTableService;
|
||||
}
|
||||
if (serviceClass === DataTableAggregateService) {
|
||||
return mockDataTableAggregateService;
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
mockDataTableService.getProjectIdForDataTable.mockResolvedValue(projectId);
|
||||
mockDataTableService.getCachedSizeBytesByIds.mockResolvedValue(new Map());
|
||||
|
||||
mockResponse = {
|
||||
json: vi.fn().mockReturnThis(),
|
||||
status: vi.fn().mockReturnThis(),
|
||||
send: vi.fn().mockReturnThis(),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('updateDataTableRows', () => {
|
||||
it('should update rows and return true when returnData is false', async () => {
|
||||
// Arrange
|
||||
const req = {
|
||||
params: { dataTableId },
|
||||
body: {
|
||||
filter: { type: 'and', filters: [{ columnName: 'id', condition: 'eq', value: 1 }] },
|
||||
data: { status: 'updated' },
|
||||
returnData: false,
|
||||
dryRun: false,
|
||||
},
|
||||
user: { id: userId },
|
||||
} as unknown as DataTableRequest.UpdateRows;
|
||||
|
||||
mockDataTableService.updateRows.mockResolvedValue(true);
|
||||
|
||||
// Act
|
||||
await handler.updateDataTableRows[2](req, mockResponse as Response);
|
||||
|
||||
// Assert
|
||||
expect(mockDataTableService.updateRows).toHaveBeenCalledWith(
|
||||
dataTableId,
|
||||
projectId,
|
||||
{
|
||||
filter: { type: 'and', filters: [{ columnName: 'id', condition: 'eq', value: 1 }] },
|
||||
data: { status: 'updated' },
|
||||
},
|
||||
false,
|
||||
false,
|
||||
);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('should update rows and return updated rows when returnData is true', async () => {
|
||||
// Arrange
|
||||
const req = {
|
||||
params: { dataTableId },
|
||||
body: {
|
||||
filter: { type: 'and', filters: [{ columnName: 'id', condition: 'eq', value: 1 }] },
|
||||
data: { status: 'updated' },
|
||||
returnData: true,
|
||||
dryRun: false,
|
||||
},
|
||||
user: makeUser(['dataTable:readRow']),
|
||||
} as unknown as DataTableRequest.UpdateRows;
|
||||
|
||||
const mockRow = { id: 1, status: 'updated', createdAt: new Date(), updatedAt: new Date() };
|
||||
mockDataTableService.updateRows.mockResolvedValue([mockRow] as any);
|
||||
|
||||
// Act
|
||||
await handler.updateDataTableRows[2](req, mockResponse as Response);
|
||||
|
||||
// Assert
|
||||
expect(mockResponse.json).toHaveBeenCalledWith([mockRow]);
|
||||
});
|
||||
|
||||
it('should support dry run mode', async () => {
|
||||
// Arrange
|
||||
const req = {
|
||||
params: { dataTableId },
|
||||
body: {
|
||||
filter: {
|
||||
type: 'and',
|
||||
filters: [{ columnName: 'id', condition: 'eq', value: 1 }],
|
||||
},
|
||||
data: { status: 'test' },
|
||||
returnData: true,
|
||||
dryRun: true,
|
||||
},
|
||||
user: makeUser(['dataTable:readRow']),
|
||||
} as unknown as DataTableRequest.UpdateRows;
|
||||
|
||||
const mockDryRunResult = [
|
||||
{ id: 1, status: 'old', dryRunState: 'before' },
|
||||
{ id: 1, status: 'test', dryRunState: 'after' },
|
||||
];
|
||||
mockDataTableService.updateRows.mockResolvedValue(mockDryRunResult as any);
|
||||
|
||||
// Act
|
||||
await handler.updateDataTableRows[2](req, mockResponse as Response);
|
||||
|
||||
// Assert
|
||||
expect(mockDataTableService.updateRows).toHaveBeenCalledWith(
|
||||
dataTableId,
|
||||
projectId,
|
||||
expect.any(Object),
|
||||
true,
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteDataTableRows', () => {
|
||||
it('should delete rows and return true when returnData is false', async () => {
|
||||
// Arrange
|
||||
const filterStr = JSON.stringify({
|
||||
type: 'and',
|
||||
filters: [{ columnName: 'status', condition: 'eq', value: 'archived' }],
|
||||
});
|
||||
const req = {
|
||||
params: { dataTableId },
|
||||
query: {
|
||||
filter: filterStr,
|
||||
returnData: 'false',
|
||||
dryRun: 'false',
|
||||
},
|
||||
user: { id: userId },
|
||||
} as unknown as DataTableRequest.DeleteRows;
|
||||
|
||||
mockDataTableService.deleteRows.mockResolvedValue(true);
|
||||
|
||||
// Act
|
||||
await handler.deleteDataTableRows[2](req, mockResponse as Response);
|
||||
|
||||
// Assert
|
||||
expect(mockDataTableService.deleteRows).toHaveBeenCalledWith(
|
||||
dataTableId,
|
||||
projectId,
|
||||
{
|
||||
filter: JSON.parse(filterStr),
|
||||
},
|
||||
false,
|
||||
false,
|
||||
);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('should delete rows and return deleted rows when returnData is true', async () => {
|
||||
// Arrange
|
||||
const filterStr = JSON.stringify({
|
||||
type: 'and',
|
||||
filters: [{ columnName: 'id', condition: 'eq', value: 1 }],
|
||||
});
|
||||
const req = {
|
||||
params: { dataTableId },
|
||||
query: {
|
||||
filter: filterStr,
|
||||
returnData: 'true',
|
||||
dryRun: 'false',
|
||||
},
|
||||
user: makeUser(['dataTable:readRow']),
|
||||
} as unknown as DataTableRequest.DeleteRows;
|
||||
|
||||
const mockRow = { id: 1, name: 'Deleted', createdAt: new Date(), updatedAt: new Date() };
|
||||
mockDataTableService.deleteRows.mockResolvedValue([mockRow] as any);
|
||||
|
||||
// Act
|
||||
await handler.deleteDataTableRows[2](req, mockResponse as Response);
|
||||
|
||||
// Assert
|
||||
expect(mockResponse.json).toHaveBeenCalledWith([mockRow]);
|
||||
});
|
||||
|
||||
it('should throw BadRequestError when filter is missing', async () => {
|
||||
// Arrange
|
||||
const req = {
|
||||
params: { dataTableId },
|
||||
query: {},
|
||||
user: { id: userId },
|
||||
} as unknown as DataTableRequest.DeleteRows;
|
||||
|
||||
// Act
|
||||
const handlerFn = handler.deleteDataTableRows[2];
|
||||
let caught: unknown;
|
||||
try {
|
||||
await handlerFn(req, mockResponse as Response);
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
|
||||
// Assert
|
||||
expect(caught).toBeInstanceOf(BadRequestError);
|
||||
expect(caught).toMatchObject({
|
||||
message: 'Required',
|
||||
httpStatusCode: 400,
|
||||
});
|
||||
});
|
||||
|
||||
it('should support dry run mode', async () => {
|
||||
// Arrange
|
||||
const filterStr = JSON.stringify({
|
||||
type: 'and',
|
||||
filters: [{ columnName: 'status', condition: 'eq', value: 'test' }],
|
||||
});
|
||||
const req = {
|
||||
params: { dataTableId },
|
||||
query: {
|
||||
filter: filterStr,
|
||||
returnData: 'true',
|
||||
dryRun: 'true',
|
||||
},
|
||||
user: makeUser(['dataTable:readRow']),
|
||||
} as unknown as DataTableRequest.DeleteRows;
|
||||
|
||||
const mockRows = [{ id: 1, status: 'test', createdAt: new Date(), updatedAt: new Date() }];
|
||||
mockDataTableService.deleteRows.mockResolvedValue(mockRows as any);
|
||||
|
||||
// Act
|
||||
await handler.deleteDataTableRows[2](req, mockResponse as Response);
|
||||
|
||||
// Assert
|
||||
expect(mockDataTableService.deleteRows).toHaveBeenCalledWith(
|
||||
dataTableId,
|
||||
projectId,
|
||||
expect.objectContaining({ filter: expect.any(Object) }),
|
||||
true,
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Security - Cross-Project Access', () => {
|
||||
const otherUserDataTableId = 'other-user-data-table-id';
|
||||
|
||||
it('should throw NotFoundError when trying to update rows in another users data table', async () => {
|
||||
// Arrange
|
||||
const req = {
|
||||
params: { dataTableId: otherUserDataTableId },
|
||||
body: {
|
||||
filter: { type: 'and', filters: [{ columnName: 'id', condition: 'eq', value: 1 }] },
|
||||
data: { status: 'hacked' },
|
||||
returnData: false,
|
||||
dryRun: false,
|
||||
},
|
||||
user: { id: userId },
|
||||
} as unknown as DataTableRequest.UpdateRows;
|
||||
|
||||
mockDataTableService.updateRows.mockRejectedValue(
|
||||
new DataTableNotFoundError(otherUserDataTableId),
|
||||
);
|
||||
|
||||
// Act
|
||||
const handlerFn = handler.updateDataTableRows[2];
|
||||
let caught: unknown;
|
||||
try {
|
||||
await handlerFn(req, mockResponse as Response);
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
|
||||
// Assert
|
||||
expect(caught).toBeInstanceOf(NotFoundError);
|
||||
expect(caught).toMatchObject({
|
||||
message: expect.stringContaining(otherUserDataTableId),
|
||||
httpStatusCode: 404,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw NotFoundError when trying to delete rows from another users data table', async () => {
|
||||
// Arrange
|
||||
const filterStr = JSON.stringify({
|
||||
type: 'and',
|
||||
filters: [{ columnName: 'id', condition: 'eq', value: 1 }],
|
||||
});
|
||||
const req = {
|
||||
params: { dataTableId: otherUserDataTableId },
|
||||
query: {
|
||||
filter: filterStr,
|
||||
returnData: 'false',
|
||||
dryRun: 'false',
|
||||
},
|
||||
user: { id: userId },
|
||||
} as unknown as DataTableRequest.DeleteRows;
|
||||
|
||||
mockDataTableService.deleteRows.mockRejectedValue(
|
||||
new DataTableNotFoundError(otherUserDataTableId),
|
||||
);
|
||||
|
||||
// Act
|
||||
const handlerFn = handler.deleteDataTableRows[2];
|
||||
let caught: unknown;
|
||||
try {
|
||||
await handlerFn(req, mockResponse as Response);
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
|
||||
// Assert
|
||||
expect(caught).toBeInstanceOf(NotFoundError);
|
||||
expect(caught).toMatchObject({
|
||||
message: expect.stringContaining(otherUserDataTableId),
|
||||
httpStatusCode: 404,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not leak information about data table existence in error messages', async () => {
|
||||
// Arrange
|
||||
const nonExistentDataTableId = 'non-existent-table-id';
|
||||
const filterStr = JSON.stringify({
|
||||
type: 'and',
|
||||
filters: [{ columnName: 'id', condition: 'eq', value: 1 }],
|
||||
});
|
||||
const req = {
|
||||
params: { dataTableId: nonExistentDataTableId },
|
||||
query: { filter: filterStr },
|
||||
user: { id: userId },
|
||||
} as unknown as DataTableRequest.DeleteRows;
|
||||
|
||||
mockDataTableService.getProjectIdForDataTable.mockRejectedValue(
|
||||
new DataTableNotFoundError(nonExistentDataTableId),
|
||||
);
|
||||
|
||||
// Act
|
||||
const handlerFn = handler.deleteDataTableRows[2];
|
||||
let caught: unknown;
|
||||
try {
|
||||
await handlerFn(req, mockResponse as Response);
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
|
||||
// Assert
|
||||
// The error message should be the same whether:
|
||||
// 1. The table doesn't exist at all
|
||||
// 2. The table exists but belongs to another user's project
|
||||
// This prevents information leakage
|
||||
expect(caught).toBeInstanceOf(NotFoundError);
|
||||
expect(caught).toMatchObject({
|
||||
message: expect.stringContaining(nonExistentDataTableId),
|
||||
httpStatusCode: 404,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
import { stringifyQuery } from '../data-tables.utils';
|
||||
|
||||
describe('stringifyQuery', () => {
|
||||
it('converts values to strings', () => {
|
||||
expect(stringifyQuery({ limit: 10, sortBy: 'id', includeCount: true })).toEqual({
|
||||
limit: '10',
|
||||
sortBy: 'id',
|
||||
includeCount: 'true',
|
||||
});
|
||||
});
|
||||
|
||||
it('drops undefined and null values', () => {
|
||||
expect(stringifyQuery({ limit: 10, cursor: undefined, filter: null })).toEqual({
|
||||
limit: '10',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an empty object for an empty query', () => {
|
||||
expect(stringifyQuery({})).toEqual({});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,111 +0,0 @@
|
|||
import { UpdateDataTableRowDto, DeleteDataTableRowsDto } from '@n8n/api-types';
|
||||
import { Container } from '@n8n/di';
|
||||
|
||||
import { BadRequestError, NotFoundError } from '@n8n/errors';
|
||||
import { assertRowReadAccessIfReturningRows } from '@/modules/data-table/data-table-permissions';
|
||||
import { DataTableService } from '@/modules/data-table/data-table.service';
|
||||
import { DataTableNotFoundError } from '@/modules/data-table/errors/data-table-not-found.error';
|
||||
import { DataTableValidationError } from '@/modules/data-table/errors/data-table-validation.error';
|
||||
|
||||
import type { DataTableRequest } from '../../../types';
|
||||
import type { PublicAPIEndpoint } from '../../shared/handler.types';
|
||||
import { publicApiScope, projectScope } from '../../shared/middlewares/global.middleware';
|
||||
import { stringifyQuery } from './data-tables.utils';
|
||||
|
||||
const handleError = (error: unknown) => {
|
||||
if (error instanceof DataTableNotFoundError) {
|
||||
throw new NotFoundError(error.message);
|
||||
}
|
||||
if (error instanceof DataTableValidationError) {
|
||||
throw new BadRequestError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
};
|
||||
|
||||
type DataTableRowsHandlers = {
|
||||
updateDataTableRows: PublicAPIEndpoint<DataTableRequest.UpdateRows>;
|
||||
clearDataTableRows: PublicAPIEndpoint<DataTableRequest.Clear>;
|
||||
deleteDataTableRows: PublicAPIEndpoint<DataTableRequest.DeleteRows>;
|
||||
};
|
||||
|
||||
const dataTableRowsHandlers: DataTableRowsHandlers = {
|
||||
updateDataTableRows: [
|
||||
publicApiScope('dataTableRow:update'),
|
||||
projectScope('dataTable:writeRow', 'dataTable'),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { dataTableId } = req.params;
|
||||
|
||||
const payload = UpdateDataTableRowDto.safeParse(req.body);
|
||||
if (!payload.success) {
|
||||
throw new BadRequestError(payload.error.errors[0]?.message || 'Invalid request body');
|
||||
}
|
||||
|
||||
const projectId =
|
||||
await Container.get(DataTableService).getProjectIdForDataTable(dataTableId);
|
||||
const service = Container.get(DataTableService);
|
||||
const { filter, data, returnData = false, dryRun = false } = payload.data;
|
||||
const params = { filter, data };
|
||||
|
||||
await assertRowReadAccessIfReturningRows(req.user, dataTableId, { dryRun, returnData });
|
||||
|
||||
const result = await service.updateRows(dataTableId, projectId, params, returnData, dryRun);
|
||||
|
||||
return res.json(result);
|
||||
} catch (error) {
|
||||
return handleError(error);
|
||||
}
|
||||
},
|
||||
],
|
||||
|
||||
clearDataTableRows: [
|
||||
publicApiScope('dataTableRow:delete'),
|
||||
projectScope('dataTable:writeRow', 'dataTable'),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { dataTableId } = req.params;
|
||||
|
||||
const projectId =
|
||||
await Container.get(DataTableService).getProjectIdForDataTable(dataTableId);
|
||||
|
||||
const result = await Container.get(DataTableService).clearRows(dataTableId, projectId);
|
||||
|
||||
return res.json(result);
|
||||
} catch (error) {
|
||||
return handleError(error);
|
||||
}
|
||||
},
|
||||
],
|
||||
|
||||
deleteDataTableRows: [
|
||||
publicApiScope('dataTableRow:delete'),
|
||||
projectScope('dataTable:writeRow', 'dataTable'),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { dataTableId } = req.params;
|
||||
|
||||
const payload = DeleteDataTableRowsDto.safeParse(stringifyQuery(req.query));
|
||||
if (!payload.success) {
|
||||
throw new BadRequestError(payload.error.errors[0]?.message || 'Invalid query parameters');
|
||||
}
|
||||
|
||||
const projectId =
|
||||
await Container.get(DataTableService).getProjectIdForDataTable(dataTableId);
|
||||
const service = Container.get(DataTableService);
|
||||
const { filter, returnData = false, dryRun = false } = payload.data;
|
||||
const params = { filter };
|
||||
|
||||
await assertRowReadAccessIfReturningRows(req.user, dataTableId, { dryRun, returnData });
|
||||
|
||||
const result = await service.deleteRows(dataTableId, projectId, params, returnData, dryRun);
|
||||
|
||||
return res.json(result);
|
||||
} catch (error) {
|
||||
return handleError(error);
|
||||
}
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export = dataTableRowsHandlers;
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
/**
|
||||
* Convert all query parameter values to strings for DTO validation.
|
||||
* Express/Supertest may parse some values as numbers/booleans.
|
||||
*/
|
||||
export const stringifyQuery = (
|
||||
query: Record<string, unknown>,
|
||||
): Record<string, string | undefined> => {
|
||||
const result: Record<string, string | undefined> = {};
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value !== undefined && value !== null) {
|
||||
result[key] = String(value);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
operationId: clearDataTableRows
|
||||
tags:
|
||||
- DataTable
|
||||
summary: Clear all rows from a data table
|
||||
description: Permanently delete all rows from a data table. The table structure is retained. This action cannot be undone.
|
||||
x-required-scope: dataTableRow:delete
|
||||
x-eov-operation-id: unreachable
|
||||
x-eov-operation-handler: v1/handlers/decorator-routed.handler
|
||||
x-decorator-routed: true
|
||||
parameters:
|
||||
- schema:
|
||||
type: string
|
||||
format: nanoid
|
||||
pattern: ^[A-Za-z0-9]{16}$
|
||||
required: true
|
||||
description: The ID of the data table
|
||||
name: dataTableId
|
||||
in: path
|
||||
responses:
|
||||
'200':
|
||||
description: Operation successful.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
deletedCount:
|
||||
type: integer
|
||||
description: The number of rows that were deleted
|
||||
required:
|
||||
- deletedCount
|
||||
'401':
|
||||
$ref: ../../../../shared/spec/responses/unauthorized.yml
|
||||
'403':
|
||||
$ref: ../../../../shared/spec/responses/forbidden.yml
|
||||
'404':
|
||||
$ref: ../../../../shared/spec/responses/notFound.yml
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
delete:
|
||||
x-eov-operation-id: clearDataTableRows
|
||||
x-required-scope: dataTableRow:delete
|
||||
x-eov-operation-handler: v1/handlers/data-tables/data-tables.rows.handler
|
||||
tags:
|
||||
- DataTable
|
||||
summary: Clear all rows from a data table
|
||||
description: Permanently deletes all rows from a data table. The table structure will be retained. This action cannot be undone.
|
||||
operationId: clear-data-table-rows
|
||||
parameters:
|
||||
- $ref: '../schemas/parameters/dataTableId.yml'
|
||||
responses:
|
||||
'200':
|
||||
description: Rows cleared successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
deletedCount:
|
||||
type: integer
|
||||
description: The number of rows that were deleted
|
||||
'401':
|
||||
$ref: '../../../../shared/spec/responses/unauthorized.yml'
|
||||
'404':
|
||||
$ref: '../../../../shared/spec/responses/notFound.yml'
|
||||
security:
|
||||
- ApiKeyAuth: []
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
delete:
|
||||
x-eov-operation-id: deleteDataTableRows
|
||||
x-required-scope: dataTableRow:delete
|
||||
x-eov-operation-handler: v1/handlers/data-tables/data-tables.rows.handler
|
||||
tags:
|
||||
- DataTable
|
||||
summary: Delete rows from a data table
|
||||
description: Delete rows matching filter conditions from a data table. Filter is required to prevent accidental deletion of all data.
|
||||
operationId: delete-data-table-rows
|
||||
parameters:
|
||||
- $ref: '../schemas/parameters/dataTableId.yml'
|
||||
- name: filter
|
||||
in: query
|
||||
required: true
|
||||
description: JSON string of filter conditions. Required to prevent accidental deletion of all data.
|
||||
schema:
|
||||
type: string
|
||||
format: jsonString
|
||||
example: '{"type":"and","filters":[{"columnName":"status","condition":"eq","value":"archived"}]}'
|
||||
- name: returnData
|
||||
in: query
|
||||
description: If true, return the deleted rows; if false, return true on success
|
||||
schema:
|
||||
type: boolean
|
||||
default: false
|
||||
- name: dryRun
|
||||
in: query
|
||||
description: If true, preview which rows would be deleted without actually deleting them
|
||||
schema:
|
||||
type: boolean
|
||||
default: false
|
||||
responses:
|
||||
'200':
|
||||
description: Rows deleted successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
oneOf:
|
||||
- type: boolean
|
||||
description: True when returnData is false
|
||||
- type: array
|
||||
items:
|
||||
$ref: '../schemas/dataTableRow.yml'
|
||||
description: Deleted rows when returnData is true
|
||||
'400':
|
||||
$ref: '../../../../shared/spec/responses/badRequest.yml'
|
||||
'401':
|
||||
$ref: '../../../../shared/spec/responses/unauthorized.yml'
|
||||
'404':
|
||||
$ref: '../../../../shared/spec/responses/notFound.yml'
|
||||
security:
|
||||
- ApiKeyAuth: []
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
patch:
|
||||
x-eov-operation-id: updateDataTableRows
|
||||
x-required-scope: dataTableRow:update
|
||||
x-eov-operation-handler: v1/handlers/data-tables/data-tables.rows.handler
|
||||
tags:
|
||||
- DataTable
|
||||
summary: Update rows in a data table
|
||||
description: Update rows matching filter conditions in a data table.
|
||||
operationId: update-data-table-rows
|
||||
parameters:
|
||||
- $ref: '../schemas/parameters/dataTableId.yml'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '../schemas/updateRowsRequest.yml'
|
||||
example:
|
||||
filter:
|
||||
type: 'and'
|
||||
filters:
|
||||
- columnName: 'status'
|
||||
condition: 'eq'
|
||||
value: 'pending'
|
||||
data:
|
||||
status: 'completed'
|
||||
updatedBy: 'admin'
|
||||
returnData: false
|
||||
dryRun: false
|
||||
responses:
|
||||
'200':
|
||||
description: Rows updated successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
oneOf:
|
||||
- type: boolean
|
||||
description: True when returnData is false
|
||||
- type: array
|
||||
items:
|
||||
$ref: '../schemas/dataTableRow.yml'
|
||||
description: Updated rows when returnData is true
|
||||
'400':
|
||||
$ref: '../../../../shared/spec/responses/badRequest.yml'
|
||||
'401':
|
||||
$ref: '../../../../shared/spec/responses/unauthorized.yml'
|
||||
'404':
|
||||
$ref: '../../../../shared/spec/responses/notFound.yml'
|
||||
security:
|
||||
- ApiKeyAuth: []
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
operationId: deleteDataTableRows
|
||||
tags:
|
||||
- DataTable
|
||||
summary: Delete rows from a data table
|
||||
description: Delete rows matching filter conditions from a data table. Filter is required to prevent accidental deletion of all data.
|
||||
x-required-scope: dataTableRow:delete
|
||||
x-eov-operation-id: unreachable
|
||||
x-eov-operation-handler: v1/handlers/decorator-routed.handler
|
||||
x-decorator-routed: true
|
||||
parameters:
|
||||
- schema:
|
||||
type: string
|
||||
format: nanoid
|
||||
pattern: ^[A-Za-z0-9]{16}$
|
||||
required: true
|
||||
description: The ID of the data table
|
||||
name: dataTableId
|
||||
in: path
|
||||
- schema:
|
||||
type: string
|
||||
format: jsonString
|
||||
required: true
|
||||
description: JSON string of filter conditions. Required to prevent accidental deletion of all data.
|
||||
example: '{"type":"and","filters":[{"columnName":"status","condition":"eq","value":"archived"}]}'
|
||||
name: filter
|
||||
in: query
|
||||
- schema:
|
||||
type: string
|
||||
enum:
|
||||
- 'true'
|
||||
- 'false'
|
||||
default: 'false'
|
||||
required: false
|
||||
description: If true, return the deleted rows. If false, return true on success.
|
||||
name: returnData
|
||||
in: query
|
||||
- schema:
|
||||
type: string
|
||||
enum:
|
||||
- 'true'
|
||||
- 'false'
|
||||
default: 'false'
|
||||
required: false
|
||||
description: If true, preview which rows would be deleted without actually deleting them.
|
||||
name: dryRun
|
||||
in: query
|
||||
responses:
|
||||
'200':
|
||||
description: Operation successful.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
anyOf:
|
||||
- type: boolean
|
||||
description: Returned when returnData is false and dryRun is false
|
||||
- type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
nullable: true
|
||||
description: The row ID (auto-generated)
|
||||
createdAt:
|
||||
type: string
|
||||
nullable: true
|
||||
format: date-time
|
||||
description: The date and time the row was created
|
||||
updatedAt:
|
||||
type: string
|
||||
nullable: true
|
||||
format: date-time
|
||||
description: The date and time the row was last updated
|
||||
dryRunState:
|
||||
type: string
|
||||
enum:
|
||||
- before
|
||||
- after
|
||||
description: Whether this entry shows the row state before or after the change
|
||||
required:
|
||||
- id
|
||||
- createdAt
|
||||
- updatedAt
|
||||
- dryRunState
|
||||
description: 'Returned when dryRun is true: one entry per row per before/after state'
|
||||
- type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
description: The row ID (auto-generated)
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
description: The date and time the row was created
|
||||
updatedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
description: The date and time the row was last updated
|
||||
required:
|
||||
- id
|
||||
- createdAt
|
||||
- updatedAt
|
||||
description: A data table row with system columns (id, createdAt, updatedAt) and user-defined columns
|
||||
description: Returned when returnData is true and dryRun is false
|
||||
'400':
|
||||
$ref: ../../../../shared/spec/responses/badRequest.yml
|
||||
'401':
|
||||
$ref: ../../../../shared/spec/responses/unauthorized.yml
|
||||
'403':
|
||||
$ref: ../../../../shared/spec/responses/forbidden.yml
|
||||
'404':
|
||||
$ref: ../../../../shared/spec/responses/notFound.yml
|
||||
|
|
@ -0,0 +1,181 @@
|
|||
operationId: updateDataTableRows
|
||||
tags:
|
||||
- DataTable
|
||||
summary: Update rows in a data table
|
||||
description: Update rows matching filter conditions in a data table.
|
||||
x-required-scope: dataTableRow:update
|
||||
x-eov-operation-id: unreachable
|
||||
x-eov-operation-handler: v1/handlers/decorator-routed.handler
|
||||
x-decorator-routed: true
|
||||
parameters:
|
||||
- schema:
|
||||
type: string
|
||||
format: nanoid
|
||||
pattern: ^[A-Za-z0-9]{16}$
|
||||
required: true
|
||||
description: The ID of the data table
|
||||
name: dataTableId
|
||||
in: path
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
filter:
|
||||
type: object
|
||||
properties:
|
||||
type:
|
||||
anyOf:
|
||||
- type: string
|
||||
enum:
|
||||
- and
|
||||
- type: string
|
||||
enum:
|
||||
- or
|
||||
default: and
|
||||
filters:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
columnName:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 63
|
||||
pattern: ^[a-zA-Z][a-zA-Z0-9_]*$
|
||||
condition:
|
||||
anyOf:
|
||||
- type: string
|
||||
enum:
|
||||
- eq
|
||||
- type: string
|
||||
enum:
|
||||
- neq
|
||||
- type: string
|
||||
enum:
|
||||
- like
|
||||
- type: string
|
||||
enum:
|
||||
- ilike
|
||||
- type: string
|
||||
enum:
|
||||
- gt
|
||||
- type: string
|
||||
enum:
|
||||
- gte
|
||||
- type: string
|
||||
enum:
|
||||
- lt
|
||||
- type: string
|
||||
enum:
|
||||
- lte
|
||||
value:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: number
|
||||
- type: boolean
|
||||
- type: string
|
||||
- {}
|
||||
- {}
|
||||
required:
|
||||
- columnName
|
||||
- condition
|
||||
- value
|
||||
minItems: 1
|
||||
required:
|
||||
- filters
|
||||
description: Filter conditions to match rows for update.
|
||||
data:
|
||||
type: object
|
||||
additionalProperties:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: number
|
||||
- type: boolean
|
||||
- {}
|
||||
- type: string
|
||||
- {}
|
||||
description: Column values to update
|
||||
returnData:
|
||||
type: boolean
|
||||
default: false
|
||||
description: Set to true to return the updated row(s). Set to false to return true on success.
|
||||
dryRun:
|
||||
type: boolean
|
||||
default: false
|
||||
description: Set to true to preview the change without saving it.
|
||||
required:
|
||||
- filter
|
||||
- data
|
||||
responses:
|
||||
'200':
|
||||
description: Operation successful.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
anyOf:
|
||||
- type: boolean
|
||||
description: Returned when returnData is false and dryRun is false
|
||||
- type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
nullable: true
|
||||
description: The row ID (auto-generated)
|
||||
createdAt:
|
||||
type: string
|
||||
nullable: true
|
||||
format: date-time
|
||||
description: The date and time the row was created
|
||||
updatedAt:
|
||||
type: string
|
||||
nullable: true
|
||||
format: date-time
|
||||
description: The date and time the row was last updated
|
||||
dryRunState:
|
||||
type: string
|
||||
enum:
|
||||
- before
|
||||
- after
|
||||
description: Whether this entry shows the row state before or after the change
|
||||
required:
|
||||
- id
|
||||
- createdAt
|
||||
- updatedAt
|
||||
- dryRunState
|
||||
description: 'Returned when dryRun is true: one entry per row per before/after state'
|
||||
- type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
description: The row ID (auto-generated)
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
description: The date and time the row was created
|
||||
updatedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
description: The date and time the row was last updated
|
||||
required:
|
||||
- id
|
||||
- createdAt
|
||||
- updatedAt
|
||||
description: A data table row with system columns (id, createdAt, updatedAt) and user-defined columns
|
||||
description: Returned when returnData is true and dryRun is false
|
||||
'400':
|
||||
$ref: ../../../../shared/spec/responses/badRequest.yml
|
||||
'401':
|
||||
$ref: ../../../../shared/spec/responses/unauthorized.yml
|
||||
'403':
|
||||
$ref: ../../../../shared/spec/responses/forbidden.yml
|
||||
'404':
|
||||
$ref: ../../../../shared/spec/responses/notFound.yml
|
||||
'415':
|
||||
$ref: ../../../../shared/spec/responses/unsupportedMediaType.yml
|
||||
|
|
@ -71,12 +71,6 @@ requestBody:
|
|||
- type: string
|
||||
enum:
|
||||
- lte
|
||||
- type: string
|
||||
enum:
|
||||
- isEmpty
|
||||
- type: string
|
||||
enum:
|
||||
- isNotEmpty
|
||||
value:
|
||||
anyOf:
|
||||
- type: string
|
||||
|
|
@ -89,6 +83,7 @@ requestBody:
|
|||
- columnName
|
||||
- condition
|
||||
- value
|
||||
minItems: 1
|
||||
required:
|
||||
- filters
|
||||
description: Filter conditions to match an existing row. If no row matches, n8n inserts a new row.
|
||||
|
|
@ -106,7 +101,7 @@ requestBody:
|
|||
returnData:
|
||||
type: boolean
|
||||
default: false
|
||||
description: Set to true to return the upserted row(s) matched by the filter. Set to false to return true on success.
|
||||
description: 'Set to true to return the upserted row(s): the matching rows if updated, or the new row if one was inserted. Set to false to return true on success.'
|
||||
dryRun:
|
||||
type: boolean
|
||||
default: false
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
description: The row ID (auto-generated)
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
description: The date and time the row was created
|
||||
updatedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
description: The date and time the row was last updated
|
||||
additionalProperties: true
|
||||
description: A data table row with system columns (id, createdAt, updatedAt) and user-defined columns
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
type: object
|
||||
properties:
|
||||
filter:
|
||||
type: object
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [and, or]
|
||||
default: and
|
||||
filters:
|
||||
type: array
|
||||
minItems: 1
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
columnName:
|
||||
type: string
|
||||
condition:
|
||||
type: string
|
||||
enum: [eq, neq, like, ilike, gt, gte, lt, lte]
|
||||
value: {}
|
||||
required:
|
||||
- columnName
|
||||
- condition
|
||||
- value
|
||||
required:
|
||||
- filters
|
||||
description: Filter conditions to match rows for update
|
||||
data:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
description: Column values to update
|
||||
returnData:
|
||||
type: boolean
|
||||
default: false
|
||||
description: If true, return the updated rows; if false, return true on success
|
||||
dryRun:
|
||||
type: boolean
|
||||
default: false
|
||||
description: If true, preview changes without persisting them
|
||||
required:
|
||||
- filter
|
||||
- data
|
||||
|
|
@ -92,6 +92,15 @@ paths:
|
|||
/data-tables/{dataTableId}/rows/upsert:
|
||||
post:
|
||||
$ref: ./handlers/data-tables/spec/paths/upsertDataTableRow.generated.yml
|
||||
/data-tables/{dataTableId}/rows/update:
|
||||
patch:
|
||||
$ref: ./handlers/data-tables/spec/paths/updateDataTableRows.generated.yml
|
||||
/data-tables/{dataTableId}/rows/clear:
|
||||
delete:
|
||||
$ref: ./handlers/data-tables/spec/paths/clearDataTableRows.generated.yml
|
||||
/data-tables/{dataTableId}/rows/delete:
|
||||
delete:
|
||||
$ref: ./handlers/data-tables/spec/paths/deleteDataTableRows.generated.yml
|
||||
/discover:
|
||||
get:
|
||||
$ref: ./handlers/discover/spec/paths/getDiscover.generated.yml
|
||||
|
|
|
|||
|
|
@ -85,12 +85,6 @@ paths:
|
|||
$ref: './handlers/ldap/spec/paths/settings.ldap.sync.yml'
|
||||
/workflows/{id}/{versionId}:
|
||||
$ref: './handlers/workflows/spec/paths/workflows.id.versionId.yml'
|
||||
/data-tables/{dataTableId}/rows/update:
|
||||
$ref: './handlers/data-tables/spec/paths/data-tables.dataTableId.rows.update.yml'
|
||||
/data-tables/{dataTableId}/rows/clear:
|
||||
$ref: './handlers/data-tables/spec/paths/data-tables.dataTableId.rows.clear.yml'
|
||||
/data-tables/{dataTableId}/rows/delete:
|
||||
$ref: './handlers/data-tables/spec/paths/data-tables.dataTableId.rows.delete.yml'
|
||||
/n8n-packages/export:
|
||||
$ref: './handlers/n8n-packages/spec/paths/n8n-packages.export.yml'
|
||||
/n8n-packages/import:
|
||||
|
|
|
|||
|
|
@ -1257,6 +1257,73 @@ describe('PATCH /data-tables/:dataTableId/rows/update', () => {
|
|||
testWithAPIKey('patch', '/data-tables/123/rows/update', 'abcXYZ'),
|
||||
);
|
||||
|
||||
test('should reject updating rows without dataTableRow:update', async () => {
|
||||
const dataTable = await createDataTable(ownerPersonalProject, {
|
||||
columns: [{ name: 'status', type: 'string' }],
|
||||
data: [{ status: 'pending' }],
|
||||
});
|
||||
const unscopedAgent = await createUnscopedAgent();
|
||||
|
||||
const response = await unscopedAgent.patch(`/data-tables/${dataTable.id}/rows/update`).send({
|
||||
filter: {
|
||||
type: 'and',
|
||||
filters: [{ columnName: 'status', condition: 'eq', value: 'pending' }],
|
||||
},
|
||||
data: { status: 'completed' },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
expect(response.body).toHaveProperty('message', 'Forbidden');
|
||||
});
|
||||
|
||||
test('should reject a filter entry missing condition', async () => {
|
||||
const dataTable = await createDataTable(ownerPersonalProject, {
|
||||
columns: [{ name: 'status', type: 'string' }],
|
||||
data: [{ status: 'pending' }],
|
||||
});
|
||||
|
||||
const response = await authOwnerAgent.patch(`/data-tables/${dataTable.id}/rows/update`).send({
|
||||
filter: {
|
||||
type: 'and',
|
||||
filters: [{ columnName: 'status', value: 'pending' }],
|
||||
},
|
||||
data: { status: 'completed' },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
test('should reject an empty filters array', async () => {
|
||||
const dataTable = await createDataTable(ownerPersonalProject, {
|
||||
columns: [{ name: 'status', type: 'string' }],
|
||||
data: [{ status: 'pending' }],
|
||||
});
|
||||
|
||||
const response = await authOwnerAgent.patch(`/data-tables/${dataTable.id}/rows/update`).send({
|
||||
filter: { type: 'and', filters: [] },
|
||||
data: { status: 'completed' },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
test('should reject isEmpty and isNotEmpty conditions', async () => {
|
||||
const dataTable = await createDataTable(ownerPersonalProject, {
|
||||
columns: [{ name: 'status', type: 'string' }],
|
||||
data: [{ status: 'pending' }],
|
||||
});
|
||||
|
||||
const response = await authOwnerAgent.patch(`/data-tables/${dataTable.id}/rows/update`).send({
|
||||
filter: {
|
||||
type: 'and',
|
||||
filters: [{ columnName: 'status', condition: 'isEmpty', value: null }],
|
||||
},
|
||||
data: { status: 'completed' },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
test('should update rows with returnData false', async () => {
|
||||
const dataTable = await createDataTable(ownerPersonalProject, {
|
||||
columns: [
|
||||
|
|
@ -1392,6 +1459,37 @@ describe('POST /data-tables/:dataTableId/rows/upsert', () => {
|
|||
expect(response.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
test('should reject an empty filters array', async () => {
|
||||
const dataTable = await createDataTable(ownerPersonalProject, {
|
||||
columns: [{ name: 'email', type: 'string' }],
|
||||
data: [{ email: 'test@example.com' }],
|
||||
});
|
||||
|
||||
const response = await authOwnerAgent.post(`/data-tables/${dataTable.id}/rows/upsert`).send({
|
||||
filter: { type: 'and', filters: [] },
|
||||
data: { email: 'updated@example.com' },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
test('should reject isEmpty and isNotEmpty conditions', async () => {
|
||||
const dataTable = await createDataTable(ownerPersonalProject, {
|
||||
columns: [{ name: 'email', type: 'string' }],
|
||||
data: [{ email: 'test@example.com' }],
|
||||
});
|
||||
|
||||
const response = await authOwnerAgent.post(`/data-tables/${dataTable.id}/rows/upsert`).send({
|
||||
filter: {
|
||||
type: 'and',
|
||||
filters: [{ columnName: 'email', condition: 'isNotEmpty', value: null }],
|
||||
},
|
||||
data: { email: 'updated@example.com' },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
test('should upsert row with returnData false', async () => {
|
||||
const dataTable = await createDataTable(ownerPersonalProject, {
|
||||
columns: [
|
||||
|
|
@ -1489,6 +1587,85 @@ describe('DELETE /data-tables/:dataTableId/rows/delete', () => {
|
|||
testWithAPIKey('delete', '/data-tables/123/rows/delete', 'abcXYZ'),
|
||||
);
|
||||
|
||||
test('should reject deleting rows without dataTableRow:delete', async () => {
|
||||
const dataTable = await createDataTable(ownerPersonalProject, {
|
||||
columns: [{ name: 'status', type: 'string' }],
|
||||
data: [{ status: 'old' }],
|
||||
});
|
||||
const unscopedAgent = await createUnscopedAgent();
|
||||
|
||||
const filter = JSON.stringify({
|
||||
type: 'and',
|
||||
filters: [{ columnName: 'status', condition: 'eq', value: 'old' }],
|
||||
});
|
||||
|
||||
const response = await unscopedAgent
|
||||
.delete(`/data-tables/${dataTable.id}/rows/delete`)
|
||||
.query({ filter });
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
expect(response.body).toHaveProperty('message', 'Forbidden');
|
||||
});
|
||||
|
||||
test('should default a filter entry missing condition to eq', async () => {
|
||||
const dataTable = await createDataTable(ownerPersonalProject, {
|
||||
columns: [{ name: 'status', type: 'string' }],
|
||||
data: [{ status: 'old' }, { status: 'active' }],
|
||||
});
|
||||
|
||||
const filter = JSON.stringify({
|
||||
type: 'and',
|
||||
filters: [{ columnName: 'status', value: 'old' }],
|
||||
});
|
||||
|
||||
const response = await authOwnerAgent
|
||||
.delete(`/data-tables/${dataTable.id}/rows/delete`)
|
||||
.query({ filter, returnData: 'true' });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body).toHaveLength(1);
|
||||
expect(response.body[0]).toHaveProperty('status', 'old');
|
||||
});
|
||||
|
||||
test('should accept isEmpty condition', async () => {
|
||||
const dataTable = await createDataTable(ownerPersonalProject, {
|
||||
columns: [{ name: 'status', type: 'string' }],
|
||||
data: [{ status: null }, { status: 'archived' }],
|
||||
});
|
||||
|
||||
const filter = JSON.stringify({
|
||||
type: 'and',
|
||||
filters: [{ columnName: 'status', condition: 'isEmpty', value: null }],
|
||||
});
|
||||
|
||||
const response = await authOwnerAgent
|
||||
.delete(`/data-tables/${dataTable.id}/rows/delete`)
|
||||
.query({ filter, returnData: 'true' });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('should accept isNotEmpty condition', async () => {
|
||||
const dataTable = await createDataTable(ownerPersonalProject, {
|
||||
columns: [{ name: 'status', type: 'string' }],
|
||||
data: [{ status: null }, { status: 'archived' }],
|
||||
});
|
||||
|
||||
const filter = JSON.stringify({
|
||||
type: 'and',
|
||||
filters: [{ columnName: 'status', condition: 'isNotEmpty', value: null }],
|
||||
});
|
||||
|
||||
const response = await authOwnerAgent
|
||||
.delete(`/data-tables/${dataTable.id}/rows/delete`)
|
||||
.query({ filter, returnData: 'true' });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body).toHaveLength(1);
|
||||
expect(response.body[0]).toHaveProperty('status', 'archived');
|
||||
});
|
||||
|
||||
test('should delete rows with returnData false', async () => {
|
||||
const dataTable = await createDataTable(ownerPersonalProject, {
|
||||
columns: [{ name: 'status', type: 'string' }],
|
||||
|
|
@ -1584,6 +1761,95 @@ describe('DELETE /data-tables/:dataTableId/rows/delete', () => {
|
|||
"request/query must have required property 'filter'",
|
||||
);
|
||||
});
|
||||
|
||||
test('should reject a non-boolean dryRun value instead of coercing it', async () => {
|
||||
const dataTable = await createDataTable(ownerPersonalProject, {
|
||||
columns: [{ name: 'status', type: 'string' }],
|
||||
data: [{ status: 'old' }],
|
||||
});
|
||||
|
||||
const filter = JSON.stringify({
|
||||
type: 'and',
|
||||
filters: [{ columnName: 'status', condition: 'eq', value: 'old' }],
|
||||
});
|
||||
|
||||
const response = await authOwnerAgent
|
||||
.delete(`/data-tables/${dataTable.id}/rows/delete`)
|
||||
.query({ filter, dryRun: '1' });
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
|
||||
// Verify data was not actually deleted
|
||||
const getResponse = await authOwnerAgent.get(`/data-tables/${dataTable.id}/rows`);
|
||||
expect(getResponse.body.data).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('should reject an empty filters array with the delete-specific message', async () => {
|
||||
const dataTable = await createDataTable(ownerPersonalProject, {
|
||||
columns: [{ name: 'status', type: 'string' }],
|
||||
data: [{ status: 'old' }],
|
||||
});
|
||||
|
||||
const filter = JSON.stringify({ type: 'and', filters: [] });
|
||||
|
||||
const response = await authOwnerAgent
|
||||
.delete(`/data-tables/${dataTable.id}/rows/delete`)
|
||||
.query({ filter });
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.body.message).toContain(
|
||||
'At least one filter condition is required for delete operations',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /data-tables/:dataTableId/rows/clear', () => {
|
||||
test(
|
||||
'should fail due to missing API Key',
|
||||
testWithAPIKey('delete', '/data-tables/123/rows/clear', null),
|
||||
);
|
||||
|
||||
test(
|
||||
'should fail due to invalid API Key',
|
||||
testWithAPIKey('delete', '/data-tables/123/rows/clear', 'abcXYZ'),
|
||||
);
|
||||
|
||||
test('should reject clearing rows without dataTableRow:delete', async () => {
|
||||
const dataTable = await createDataTable(ownerPersonalProject, {
|
||||
columns: [{ name: 'status', type: 'string' }],
|
||||
});
|
||||
const unscopedAgent = await createUnscopedAgent();
|
||||
|
||||
const response = await unscopedAgent.delete(`/data-tables/${dataTable.id}/rows/clear`);
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
test('should return 404 for non-existing data table', async () => {
|
||||
const nonExistentId = 'abcd1234efgh5678'; // Valid nanoid format but doesn't exist
|
||||
const response = await authOwnerAgent.delete(`/data-tables/${nonExistentId}/rows/clear`);
|
||||
|
||||
expect(response.statusCode).toBe(404);
|
||||
expect(response.body).toHaveProperty(
|
||||
'message',
|
||||
`Could not find the data table: '${nonExistentId}'`,
|
||||
);
|
||||
});
|
||||
|
||||
test('should clear all rows from a data table', async () => {
|
||||
const dataTable = await createDataTable(ownerPersonalProject, {
|
||||
columns: [{ name: 'status', type: 'string' }],
|
||||
data: [{ status: 'active' }, { status: 'archived' }, { status: 'active' }],
|
||||
});
|
||||
|
||||
const response = await authOwnerAgent.delete(`/data-tables/${dataTable.id}/rows/clear`);
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body).toStrictEqual({ deletedCount: 3 });
|
||||
|
||||
const getResponse = await authOwnerAgent.get(`/data-tables/${dataTable.id}/rows`);
|
||||
expect(getResponse.body.data).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('row-content authorization on write endpoints', () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user