diff --git a/adminforth/dataConnectors/baseConnector.ts b/adminforth/dataConnectors/baseConnector.ts index 940750ae0..bae636448 100644 --- a/adminforth/dataConnectors/baseConnector.ts +++ b/adminforth/dataConnectors/baseConnector.ts @@ -261,12 +261,13 @@ export default class AdminForthBaseConnector implements IAdminForthDataSourceCon } } - getDataWithOriginalTypes({ resource, limit, offset, sort, filters }: { + getDataWithOriginalTypes({ resource, limit, offset, sort, filters, columns }: { resource: AdminForthResource, limit: number, offset: number, sort: IAdminForthSort[], filters: IAdminForthAndOrFilter, + columns?: AdminForthResourceColumn[], }): Promise { throw new Error('Method not implemented.'); } @@ -595,13 +596,14 @@ export default class AdminForthBaseConnector implements IAdminForthDataSourceCon throw new Error('Method not implemented.'); } - async getData({ resource, limit, offset, sort, filters, getTotals }: { + async getData({ resource, limit, offset, sort, filters, getTotals, columns }: { resource: AdminForthResource, limit: number, offset: number, sort: { field: string, direction: AdminForthSortDirections }[], filters: IAdminForthAndOrFilter, getTotals: boolean, + columns?: AdminForthResourceColumn[], }): Promise<{ data: any[], total: number }> { let normalizedFilters = filters; @@ -613,7 +615,8 @@ export default class AdminForthBaseConnector implements IAdminForthDataSourceCon normalizedFilters = filterValidation.normalizedFilters as IAdminForthAndOrFilter; } - const promises: Promise[] = [this.getDataWithOriginalTypes({ resource, limit, offset, sort, filters: normalizedFilters })]; + const dataSourceColumns = columns ?? resource.dataSourceColumns; + const promises: Promise[] = [this.getDataWithOriginalTypes({ resource, limit, offset, sort, filters: normalizedFilters, columns: dataSourceColumns })]; if (getTotals) { promises.push(this.getCount({ resource, filters })); } else { @@ -624,7 +627,7 @@ export default class AdminForthBaseConnector implements IAdminForthDataSourceCon // call getFieldValue for each field data.map((record) => { - for (const col of resource.dataSourceColumns) { + for (const col of dataSourceColumns) { record[col.name] = this.getFieldValue(col, record[col.name]); } }); diff --git a/adminforth/dataConnectors/clickhouse.ts b/adminforth/dataConnectors/clickhouse.ts index 5c21cd783..d14c4a1d8 100644 --- a/adminforth/dataConnectors/clickhouse.ts +++ b/adminforth/dataConnectors/clickhouse.ts @@ -510,14 +510,15 @@ class ClickhouseConnector extends AdminForthBaseConnector implements IAdminForth })); } - async getDataWithOriginalTypes({ resource, limit, offset, sort, filters }: { + async getDataWithOriginalTypes({ resource, limit, offset, sort, filters, columns }: { resource: AdminForthResource, limit: number, offset: number, sort: { field: string, direction: AdminForthSortDirections }[], filters: IAdminForthAndOrFilter, + columns?: AdminForthResourceColumn[], }): Promise> { - const columns = resource.dataSourceColumns.map((col) => { + const selectedColumns = (columns ?? resource.dataSourceColumns).map((col) => { // for decimal cast to string if (col.type == AdminForthDataTypes.DECIMAL) { return `toString(${col.name}) as ${col.name}` @@ -532,7 +533,7 @@ class ClickhouseConnector extends AdminForthBaseConnector implements IAdminForth const orderBy = sort.length ? `ORDER BY ${sort.map((s) => `${s.field} ${this.SortDirectionsMap[s.direction]}`).join(', ')}` : ''; - const q = `SELECT ${columns} FROM ${tableName} ${where} ${orderBy} LIMIT {limit:Int} OFFSET {offset:Int}`; + const q = `SELECT ${selectedColumns} FROM ${tableName} ${where} ${orderBy} LIMIT {limit:Int} OFFSET {offset:Int}`; const d = { ...params, limit, diff --git a/adminforth/dataConnectors/mongo.ts b/adminforth/dataConnectors/mongo.ts index ef2b106cd..602608c50 100644 --- a/adminforth/dataConnectors/mongo.ts +++ b/adminforth/dataConnectors/mongo.ts @@ -408,13 +408,14 @@ class MongoConnector extends AdminForthBaseConnector implements IAdminForthDataS }); } - async getDataWithOriginalTypes({ resource, limit, offset, sort, filters }: + async getDataWithOriginalTypes({ resource, limit, offset, sort, filters, columns }: { resource: AdminForthResource, limit: number, offset: number, sort: { field: string, direction: AdminForthSortDirections }[], filters: IAdminForthAndOrFilter, + columns?: Array<{ name: string }>, } ): Promise { @@ -429,7 +430,11 @@ class MongoConnector extends AdminForthBaseConnector implements IAdminForthDataS return [s.field, this.SortDirectionsMap[s.direction]]; }); - const result = await collection.find(query) + const projection = columns + ? Object.fromEntries(columns.map((col) => [col.name, 1])) + : undefined; + + const result = await collection.find(query, projection ? { projection } : undefined) .sort(sortArray) .skip(offset) .limit(limit) diff --git a/adminforth/dataConnectors/mysql.ts b/adminforth/dataConnectors/mysql.ts index 63bca6e65..81aa794d2 100644 --- a/adminforth/dataConnectors/mysql.ts +++ b/adminforth/dataConnectors/mysql.ts @@ -453,14 +453,14 @@ class MysqlConnector extends AdminForthBaseConnector implements IAdminForthDataS return rows; } - async getDataWithOriginalTypes({ resource, limit, offset, sort, filters }): Promise { - const columns = resource.dataSourceColumns.map((col: { name: string }) => `${col.name}`).join(', '); + async getDataWithOriginalTypes({ resource, limit, offset, sort, filters, columns }): Promise { + const selectedColumns = (columns ?? resource.dataSourceColumns).map((col: { name: string }) => `${col.name}`).join(', '); const tableName = resource.table; const { sql: where, values: filterValues } = this.whereClauseAndValues(filters); const orderBy = sort.length ? `ORDER BY ${sort.map((s: { field: string; direction: AdminForthSortDirections }) => `${s.field} ${this.SortDirectionsMap[s.direction]}`).join(', ')}` : ''; - let selectQuery = `SELECT ${columns} FROM ${tableName}`; + let selectQuery = `SELECT ${selectedColumns} FROM ${tableName}`; if (where) selectQuery += ` ${where}`; if (orderBy) selectQuery += ` ${orderBy}`; if (limit) selectQuery += ` LIMIT ${limit}`; diff --git a/adminforth/dataConnectors/postgres.ts b/adminforth/dataConnectors/postgres.ts index f91f38cc6..98f5f1c69 100644 --- a/adminforth/dataConnectors/postgres.ts +++ b/adminforth/dataConnectors/postgres.ts @@ -428,8 +428,8 @@ class PostgresConnector extends AdminForthBaseConnector implements IAdminForthDa return stmt.rows; } - async getDataWithOriginalTypes({ resource, limit, offset, sort, filters }): Promise { - const columns = resource.dataSourceColumns.map((col) => `"${col.name}"`).join(', '); + async getDataWithOriginalTypes({ resource, limit, offset, sort, filters, columns }): Promise { + const selectedColumns = (columns ?? resource.dataSourceColumns).map((col) => `"${col.name}"`).join(', '); const tableName = resource.table; const { sql: where, paramsCount, values: filterValues } = this.whereClauseAndValues(resource, filters); @@ -437,7 +437,7 @@ class PostgresConnector extends AdminForthBaseConnector implements IAdminForthDa const limitOffset = `LIMIT $${paramsCount} OFFSET $${paramsCount + 1}`; const d = [...filterValues, limit, offset]; const orderBy = sort.length ? `ORDER BY ${sort.map((s) => `"${s.field}" ${this.SortDirectionsMap[s.direction]}`).join(', ')}` : ''; - const selectQuery = `SELECT ${columns} FROM "${tableName}" ${where} ${orderBy} ${limitOffset}`; + const selectQuery = `SELECT ${selectedColumns} FROM "${tableName}" ${where} ${orderBy} ${limitOffset}`; dbLogger.trace(`🪲📜 PG Q: ${selectQuery}, params: ${JSON.stringify(d)}`); const stmt = await this.client.query(selectQuery, d); const rows = stmt.rows; diff --git a/adminforth/dataConnectors/sqlite.ts b/adminforth/dataConnectors/sqlite.ts index 3f0e330b7..496d7ef0f 100644 --- a/adminforth/dataConnectors/sqlite.ts +++ b/adminforth/dataConnectors/sqlite.ts @@ -430,8 +430,8 @@ class SQLiteConnector extends AdminForthBaseConnector implements IAdminForthData return results.sort((a, b) => a.group.localeCompare(b.group)); } - async getDataWithOriginalTypes({ resource, limit, offset, sort, filters }): Promise { - const columns = resource.dataSourceColumns.map((col) => col.name).join(', '); + async getDataWithOriginalTypes({ resource, limit, offset, sort, filters, columns }): Promise { + const selectedColumns = (columns ?? resource.dataSourceColumns).map((col) => col.name).join(', '); const tableName = resource.table; const where = this.whereClause(filters); @@ -440,7 +440,7 @@ class SQLiteConnector extends AdminForthBaseConnector implements IAdminForthData const orderBy = sort.length ? `ORDER BY ${sort.map((s) => `${s.field} ${this.SortDirectionsMap[s.direction]}`).join(', ')}` : ''; - const q = `SELECT ${columns} FROM ${tableName} ${where} ${orderBy} LIMIT ? OFFSET ?`; + const q = `SELECT ${selectedColumns} FROM ${tableName} ${where} ${orderBy} LIMIT ? OFFSET ?`; const stmt = this.client.prepare(q); const d = [...filterValues, limit, offset]; diff --git a/adminforth/modules/restApi.ts b/adminforth/modules/restApi.ts index 7c3d7dae5..6405f122d 100644 --- a/adminforth/modules/restApi.ts +++ b/adminforth/modules/restApi.ts @@ -272,6 +272,13 @@ const getResourceDataRequestSchema: AnySchemaObject = { }, sort: commonSortSchema, filters: commonFiltersSchema, + columns: { + type: 'array', + description: 'Optional list of resource column names to include in returned rows. For list requests, computed row helper fields such as _label and _clickUrl are still returned when available.', + minItems: 1, + uniqueItems: true, + items: { type: 'string' }, + }, }, additionalProperties: true, allOf: [ @@ -1296,6 +1303,42 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { } } const { limit, offset, filters, sort } = body; + const selectedColumnNames = body.columns + ? [...new Set(body.columns as string[])] + : undefined; + + if (selectedColumnNames) { + const resourceColumnNames = new Set(resource.columns.map((col) => col.name)); + const invalidColumnName = selectedColumnNames.find((columnName) => !resourceColumnNames.has(columnName)); + + if (invalidColumnName) { + return { error: `Column ${invalidColumnName} not found in resource ${resourceId}` }; + } + } + const selectedColumnNameSet = selectedColumnNames ? new Set(selectedColumnNames) : undefined; + const shouldAddListHelpers = source === 'list'; + const selectedDataSourceColumnNameSet = selectedColumnNames + ? new Set(selectedColumnNames.filter((columnName) => resource.dataSourceColumns.some((col) => col.name === columnName))) + : undefined; + + if (selectedDataSourceColumnNameSet) { + for (const col of resource.columns) { + if ( + selectedColumnNameSet.has(col.name) && + col.foreignResource?.polymorphicOn + ) { + selectedDataSourceColumnNameSet.add(col.foreignResource.polymorphicOn); + } + } + } + + const selectedDataSourceColumns = selectedDataSourceColumnNameSet && !shouldAddListHelpers + ? ( + resource.dataSourceColumns.some((col) => selectedDataSourceColumnNameSet.has(col.name)) + ? resource.dataSourceColumns.filter((col) => selectedDataSourceColumnNameSet.has(col.name)) + : resource.dataSourceColumns.filter((col) => col.primaryKey || col.name === resource.dataSourceColumns[0]?.name) + ) + : undefined; // remove virtual fields from sort if still presented after beforeDatasourceRequest hook const sortFiltered = sort.filter((sortItem: IAdminForthSort) => { @@ -1334,11 +1377,14 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { filters: normalizedFilters as IAdminForthAndOrFilter, sort: sortFiltered, getTotals: source === 'list', + columns: selectedDataSourceColumns, }); // for foreign keys, add references await Promise.all( - resource.columns.filter((col) => col.foreignResource).map(async (col) => { + resource.columns.filter((col) => ( + col.foreignResource && (!selectedColumnNameSet || shouldAddListHelpers || selectedColumnNameSet.has(col.name)) + )).map(async (col) => { let targetDataMap = {}; if (col.foreignResource.resourceId) { @@ -1448,7 +1494,6 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { }) ); - const pkField = resource.columns.find((col) => col.primaryKey)?.name; // remove all columns which are not defined in resources, or defined but backendOnly { const ctx = { @@ -1471,10 +1516,12 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { delete item[key]; } } - item._label = resource.recordLabel(item); + if (!selectedColumnNameSet || shouldAddListHelpers) { + item._label = resource.recordLabel(item); + } } } - if (source === 'list' && resource.options.listTableClickUrl) { + if (shouldAddListHelpers && resource.options.listTableClickUrl) { await Promise.all( data.data.map(async (item) => { item._clickUrl = await resource.options.listTableClickUrl(item, adminUser, resource); @@ -1501,6 +1548,16 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { } } + if (selectedColumnNameSet) { + for (const item of data.data) { + for (const key of Object.keys(item)) { + if (!selectedColumnNameSet.has(key) && key !== '_label' && key !== '_clickUrl') { + delete item[key]; + } + } + } + } + return data; }, }); diff --git a/adminforth/types/Back.ts b/adminforth/types/Back.ts index c4fb15d72..78a480c35 100644 --- a/adminforth/types/Back.ts +++ b/adminforth/types/Back.ts @@ -334,12 +334,13 @@ export interface IAdminForthDataSourceConnector { * * Fields are returned from db "as is" then {@link AdminForthBaseConnector.getData} will transform each field using {@link IAdminForthDataSourceConnector.getFieldValue} */ - getDataWithOriginalTypes({ resource, limit, offset, sort, filters }: { + getDataWithOriginalTypes({ resource, limit, offset, sort, filters, columns }: { resource: AdminForthResource, limit: number, offset: number, sort: IAdminForthSort[], filters: IAdminForthAndOrFilter, + columns?: AdminForthResourceColumn[], }): Promise>; /** @@ -397,13 +398,14 @@ export interface IAdminForthDataSourceConnectorBase extends IAdminForthDataSourc getPrimaryKey(resource: AdminForthResource): string; - getData({ resource, limit, offset, sort, filters }: { + getData({ resource, limit, offset, sort, filters, columns }: { resource: AdminForthResource, limit: number, offset: number, sort: IAdminForthSort[], filters: IAdminForthAndOrFilter, getTotals?: boolean, + columns?: AdminForthResourceColumn[], }): Promise<{ data: Array, total: number }>; getRecordByPrimaryKey(resource: AdminForthResource, recordId: string): Promise; diff --git a/tests/application/index.ts b/tests/application/index.ts index fcab42bdd..415a3a98b 100644 --- a/tests/application/index.ts +++ b/tests/application/index.ts @@ -20,6 +20,7 @@ import cars_sl_dont_allow_delete_by_hook from './resources/cars_sl_dont_allow_de import carsDescriptionImage from '../../dev-demo/resources/cars_description_image.js'; import passkeysResource from '../../dev-demo/resources/passkeys.js'; +import polymorphicCarRefs from './resources/polymorphic_car_refs.js'; const ADMIN_BASE_URL = ''; const appFilePath = fileURLToPath(import.meta.url); @@ -95,6 +96,7 @@ export const admin = new AdminForth({ cars_sl_dont_allow_delete_by_hook, carsDescriptionImage, + polymorphicCarRefs, passkeysResource ], menu: [ diff --git a/tests/application/resources/adminuser.ts b/tests/application/resources/adminuser.ts index 208be6ddc..f7d7c67f2 100644 --- a/tests/application/resources/adminuser.ts +++ b/tests/application/resources/adminuser.ts @@ -3,6 +3,9 @@ import usersResource from "../../../dev-demo/resources/adminuser.js"; export default { ...usersResource, plugins: [ - ...usersResource.plugins?.filter(p => p.className !== 'TwoFactorsAuthPlugin') || [], + ...usersResource.plugins?.filter((p) => ![ + 'AdminForthAgentPlugin', + 'TwoFactorsAuthPlugin', + ].includes(p.className)) || [], ], } \ No newline at end of file diff --git a/tests/application/resources/cars_sl_allow_create.ts b/tests/application/resources/cars_sl_allow_create.ts index c9840a378..f33e5af33 100644 --- a/tests/application/resources/cars_sl_allow_create.ts +++ b/tests/application/resources/cars_sl_allow_create.ts @@ -5,6 +5,10 @@ const cars_sl = carsResourseTemplate("cars_sl", "sqlite", "id"); export default { ...cars_sl, + options: { + ...(cars_sl as any).options, + listTableClickUrl: async (record: any, _adminUser: any, resource: any) => `/resource/${resource.resourceId}/show/${record.id}`, + }, columns: [ ...cars_sl.columns.filter(c => c.name !== "mileage" && c.name !== "photos" && c.name !== "generated_promo_picture"), { @@ -33,4 +37,4 @@ export default { editReadonly: true, }, ], -}; \ No newline at end of file +}; diff --git a/tests/application/resources/polymorphic_car_refs.ts b/tests/application/resources/polymorphic_car_refs.ts new file mode 100644 index 000000000..137681d97 --- /dev/null +++ b/tests/application/resources/polymorphic_car_refs.ts @@ -0,0 +1,55 @@ +import { randomUUID } from 'crypto'; +import { AdminForthDataTypes, type AdminForthResourceInput } from 'adminforth'; + +export default { + dataSource: 'sqlite', + table: 'cars_description_image', + resourceId: 'polymorphic_car_refs', + label: 'Polymorphic car refs', + columns: [ + { + name: 'id', + primaryKey: true, + required: false, + fillOnCreate: () => randomUUID(), + showIn: { + create: false, + }, + }, + { + name: 'created_at', + required: false, + fillOnCreate: () => new Date().toISOString(), + showIn: { + create: false, + }, + }, + { + name: 'resource_id', + type: AdminForthDataTypes.STRING, + required: false, + showIn: { + create: false, + edit: false, + }, + }, + { + name: 'record_id', + type: AdminForthDataTypes.STRING, + required: false, + foreignResource: { + polymorphicOn: 'resource_id', + polymorphicResources: [ + { + resourceId: 'cars_sl', + whenValue: 'car', + }, + ], + }, + }, + { + name: 'image_path', + required: false, + }, + ], +} as AdminForthResourceInput; diff --git a/tests/jest_tests/CRUD_sqlite.test.ts b/tests/jest_tests/CRUD_sqlite.test.ts index 35e4d1185..ee0fc0437 100644 --- a/tests/jest_tests/CRUD_sqlite.test.ts +++ b/tests/jest_tests/CRUD_sqlite.test.ts @@ -714,6 +714,187 @@ describe('POST /get_resource_data', () => { }); }); + it('keeps computed helper fields when columns are not requested', async () => { + const res = await agent + .set('Cookie', authCookie) + .post('/adminapi/v1/get_resource_data') + .send({ + resourceId: 'cars_sl', + source: 'list', + limit: 1, + offset: 0, + sort: [], + filters: [{ field: 'id', operator: 'eq', value: createdRecordId }], + }); + + expect(res.status).toEqual(200); + expect(res.body.error).toBeUndefined(); + expect(res.body.data[0]._label).toBe('🚘 Abobus amogus 🚗'); + expect(res.body.data[0]._clickUrl).toBe(`/resource/cars_sl/show/${createdRecordId}`); + }); + + it('returns requested columns with computed list helper fields', async () => { + const res = await agent + .set('Cookie', authCookie) + .post('/adminapi/v1/get_resource_data') + .send({ + resourceId: 'cars_sl', + source: 'list', + limit: 1, + offset: 0, + sort: [], + filters: [{ field: 'id', operator: 'eq', value: createdRecordId }], + columns: ['model', 'price'], + }); + + expect(res.status).toEqual(200); + expect(res.body.error).toBeUndefined(); + expect(res.body.data[0]).toEqual({ + model: 'Abobus amogus', + price: 1234, + _label: '🚘 Abobus amogus 🚗', + _clickUrl: `/resource/cars_sl/show/${createdRecordId}`, + }); + }); + + it('returns an error for unknown requested columns', async () => { + const res = await agent + .set('Cookie', authCookie) + .post('/adminapi/v1/get_resource_data') + .send({ + resourceId: 'cars_sl', + source: 'list', + limit: 1, + offset: 0, + sort: [], + filters: [], + columns: ['missing_column'], + }); + + expect(res.status).toEqual(200); + expect(res.body.error).toBe('Column missing_column not found in resource cars_sl'); + }); + + it('supports selecting only a virtual column without leaking internal fallback columns', async () => { + const res = await agent + .set('Cookie', authCookie) + .post('/adminapi/v1/get_resource_data') + .send({ + resourceId: 'adminuser', + source: 'list', + limit: 1, + offset: 0, + sort: [], + filters: [{ field: 'email', operator: 'eq', value: 'adminforth' }], + columns: ['password'], + }); + + expect(res.status).toEqual(200); + expect(res.body.error).toBeUndefined(); + expect(res.body.data[0]).toEqual({ + _label: '👤 adminforth', + }); + }); + + it('projects requested foreign columns after reference post-processing', async () => { + const adminUserRes = await agent + .set('Cookie', authCookie) + .post('/adminapi/v1/get_resource_data') + .send({ + resourceId: 'adminuser', + source: 'list', + limit: 1, + offset: 0, + sort: [], + filters: [{ field: 'email', operator: 'eq', value: 'adminforth' }], + columns: ['id'], + }); + + expect(adminUserRes.status).toEqual(200); + expect(adminUserRes.body.error).toBeUndefined(); + const adminUserId = adminUserRes.body.data[0].id; + + const updateRes = await agent + .set('Cookie', authCookie) + .post('/adminapi/v1/update_record') + .send({ + resourceId: 'cars_sl', + recordId: createdRecordId, + meta: {}, + record: { + seller_id: adminUserId, + }, + }); + + expect(updateRes.status).toEqual(200); + expect(updateRes.body.error).toBeUndefined(); + + const res = await agent + .set('Cookie', authCookie) + .post('/adminapi/v1/get_resource_data') + .send({ + resourceId: 'cars_sl', + source: 'list', + limit: 1, + offset: 0, + sort: [], + filters: [{ field: 'id', operator: 'eq', value: createdRecordId }], + columns: ['seller_id'], + }); + + expect(res.status).toEqual(200); + expect(res.body.error).toBeUndefined(); + expect(res.body.data[0]).toMatchObject({ + seller_id: { + label: '👤 adminforth', + pk: adminUserId, + }, + _label: '🚘 Abobus amogus 🚗', + }); + }); + + it('projects polymorphic foreign columns without leaking polymorphic discriminator columns', async () => { + const createRes = await agent + .set('Cookie', authCookie) + .post('/adminapi/v1/create_record') + .send({ + resourceId: 'polymorphic_car_refs', + record: { + record_id: createdRecordId, + image_path: 'test-image.png', + }, + requiredColumnsToSkip: [], + meta: {}, + }); + + expect(createRes.status).toEqual(200); + expect(createRes.body.error).toBeUndefined(); + + const res = await agent + .set('Cookie', authCookie) + .post('/adminapi/v1/get_resource_data') + .send({ + resourceId: 'polymorphic_car_refs', + source: 'list', + limit: 1, + offset: 0, + sort: [], + filters: [{ field: 'id', operator: 'eq', value: createRes.body.newRecordId }], + columns: ['record_id'], + }); + + expect(res.status).toEqual(200); + expect(res.body.error).toBeUndefined(); + expect(res.body.data[0]).toMatchObject({ + record_id: { + label: '🚘 Abobus amogus 🚗', + pk: createdRecordId, + }, + }); + expect(res.body.data[0]._label.startsWith('Polymorphic car refs ')).toBe(true); + expect(res.body.data[0].resource_id).toBeUndefined(); + }); + describe('POST /get_resource', () => { beforeAll(async () => { const res = await agent @@ -923,4 +1104,4 @@ describe('POST /delete_record', () => { expect(res.body.error).toBe("Operation aborted by hook"); }); -}); \ No newline at end of file +});