UNPKG

apialize

Version:

Turn a database model into a production ready REST(ish) CRUD API in a few lines.

380 lines (336 loc) 11.7 kB
const express = require('express'); const bodyParser = require('body-parser'); const request = require('supertest'); const { Sequelize, DataTypes, Op } = require('sequelize'); const { create, single, update, list } = require('../src'); async function build() { const sequelize = new Sequelize('sqlite::memory:', { logging: false }); // Create models with relationships for testing include/attributes const Category = sequelize.define( 'Category', { id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, name: { type: DataTypes.STRING(100), allowNull: false }, description: { type: DataTypes.STRING(255), allowNull: true }, }, { tableName: 'mixed_hooks_categories', timestamps: false } ); const Item = sequelize.define( 'Item', { id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, external_id: { type: DataTypes.STRING(64), allowNull: false, unique: true, }, name: { type: DataTypes.STRING(100), allowNull: false }, desc: { type: DataTypes.STRING(255), allowNull: true }, category_id: { type: DataTypes.INTEGER, allowNull: true }, status: { type: DataTypes.STRING(20), allowNull: false, defaultValue: 'active', }, price: { type: DataTypes.DECIMAL(10, 2), allowNull: true }, }, { tableName: 'mixed_hooks_items', timestamps: false } ); // Set up relationships Category.hasMany(Item, { foreignKey: 'category_id', as: 'items' }); Item.belongsTo(Category, { foreignKey: 'category_id', as: 'category' }); await sequelize.sync({ force: true }); const app = express(); app.use(bodyParser.json()); // Create operation with array hooks app.use( '/items', create(Item, { pre: [ async (ctx) => { return { createStep: 1 }; }, async (ctx) => { return { createStep: 2 }; }, ], post: async (ctx) => { ctx.payload.createHook = 'single-post'; }, }) ); // Update operation with mixed hooks (single pre, array post) app.use( '/items', update(Item, { pre: async (ctx) => { return { updateStep: 1 }; }, post: [ async (ctx) => { ctx.payload.updateHook1 = 'mixed-post1'; }, async (ctx) => { ctx.payload.updateHook2 = 'mixed-post2'; }, ], }) ); return { sequelize, Item, Category, app }; } describe('mixed hooks: single functions and arrays together', () => { let sequelize; afterEach(async () => { if (sequelize) { await sequelize.close(); sequelize = null; } }); test('mixed hook types work together correctly', async () => { const { sequelize: s, app } = await build(); sequelize = s; // Test create with array pre hooks and single post hook const created = await request(app) .post('/items') .send({ external_id: 'mixed-1', name: 'MixedTest' }); expect(created.status).toBe(201); expect(created.body.success).toBe(true); expect(created.body.createHook).toBe('single-post'); const itemId = created.body.id; // Test update with single pre hook and array post hooks const updated = await request(app) .put(`/items/${itemId}`) .send({ external_id: 'mixed-1', name: 'MixedTestUpdated' }); expect(updated.status).toBe(200); expect(updated.body.success).toBe(true); expect(updated.body.updateHook1).toBe('mixed-post1'); expect(updated.body.updateHook2).toBe('mixed-post2'); }); test('execution order is preserved across different hook configurations', async () => { const { sequelize: s, app } = await build(); sequelize = s; // Create item with array pre hooks const created = await request(app) .post('/items') .send({ external_id: 'order-test', name: 'OrderTest' }); expect(created.status).toBe(201); expect(created.body.createHook).toBe('single-post'); // Update with single pre and array post hooks const updated = await request(app) .put(`/items/${created.body.id}`) .send({ external_id: 'order-test', name: 'OrderTestUpdated' }); expect(updated.status).toBe(200); expect(updated.body.updateHook1).toBe('mixed-post1'); expect(updated.body.updateHook2).toBe('mixed-post2'); }); test('pre hooks can modify where clause to filter results', async () => { const { sequelize: s, Category, Item } = await build(); sequelize = s; // Create test data const category1 = await Category.create({ name: 'Electronics', description: 'Electronic items', }); const category2 = await Category.create({ name: 'Books', description: 'Book items', }); await Item.create({ external_id: 'item1', name: 'Laptop', category_id: category1.id, status: 'active', price: 999.99, }); await Item.create({ external_id: 'item2', name: 'Mouse', category_id: category1.id, status: 'inactive', price: 29.99, }); await Item.create({ external_id: 'item3', name: 'Novel', category_id: category2.id, status: 'active', price: 25.99, }); // Create new app with list operation that modifies where clause const app = express(); app.use(bodyParser.json()); app.use( '/items', list(Item, { pre: [ async (ctx) => { // First pre hook: filter by status ctx.applyWhere({ status: 'active' }); return { step: 1 }; }, async (ctx) => { // Second pre hook: further filter by price > 20 ctx.applyWhere({ price: { [Op.gt]: 20 } }); return { step: 2, whereModified: true }; }, ], post: async (ctx) => { ctx.payload.meta.whereClauseModified = true; ctx.payload.meta.preResult = ctx.preResult; }, }) ); const response = await request(app).get('/items'); expect(response.status).toBe(200); expect(response.body.success).toBe(true); expect(response.body.data).toHaveLength(2); // Only laptop and novel (active + price > 20) expect(response.body.data.map((item) => item.name).sort()).toEqual([ 'Laptop', 'Novel', ]); expect(response.body.meta.whereClauseModified).toBe(true); expect(response.body.meta.preResult).toEqual({ step: 2, whereModified: true, }); }); test('pre hooks can modify include clause to add relations', async () => { const { sequelize: s, Category, Item } = await build(); sequelize = s; // Create test data const category = await Category.create({ name: 'Electronics', description: 'Electronic items', }); const item = await Item.create({ external_id: 'item-with-cat', name: 'Smartphone', category_id: category.id, status: 'active', }); // Create separate app for this test to avoid routing conflicts const app = express(); app.use(bodyParser.json()); app.use( '/items', single(Item, { pre: [ async (ctx) => { // First pre hook: add basic include ctx.req.apialize.options.include = [ { model: Category, as: 'category' }, ]; return { step: 1 }; }, async (ctx) => { // Second pre hook: modify attributes for the include ctx.req.apialize.options.include[0].attributes = [ 'name', 'description', ]; return { step: 2, includeModified: true }; }, ], post: async (ctx) => { ctx.payload.includeClauseModified = true; ctx.payload.preResult = ctx.preResult; }, }) ); const response = await request(app).get(`/items/${item.id}`); expect(response.status).toBe(200); expect(response.body.success).toBe(true); expect(response.body.record.name).toBe('Smartphone'); expect(response.body.record.category).toBeDefined(); expect(response.body.record.category.name).toBe('Electronics'); expect(response.body.record.category.description).toBe('Electronic items'); expect(response.body.record.category.id).toBeUndefined(); // Should be excluded due to attributes filter expect(response.body.includeClauseModified).toBe(true); expect(response.body.preResult).toEqual({ step: 2, includeModified: true }); }); test('pre hooks can modify attributes clause to control returned fields', async () => { const { sequelize: s, Item } = await build(); sequelize = s; // Create test data const item = await Item.create({ external_id: 'attr-test', name: 'Test Item', desc: 'This is a test item', status: 'active', price: 99.99, }); // Create separate app for this test to avoid routing conflicts const app = express(); app.use(bodyParser.json()); app.use( '/items', single(Item, { pre: [ async (ctx) => { // First pre hook: limit to basic fields ctx.req.apialize.options.attributes = ['id', 'name', 'external_id']; return { step: 1 }; }, async (ctx) => { // Second pre hook: add one more field ctx.req.apialize.options.attributes.push('status'); return { step: 2, attributesModified: true }; }, ], post: async (ctx) => { ctx.payload.attributesClauseModified = true; ctx.payload.preResult = ctx.preResult; }, }) ); const response = await request(app).get(`/items/${item.id}`); expect(response.status).toBe(200); expect(response.body.success).toBe(true); expect(response.body.record.id).toBe(item.id); expect(response.body.record.name).toBe('Test Item'); expect(response.body.record.external_id).toBe('attr-test'); expect(response.body.record.status).toBe('active'); // These should be excluded due to attributes filter expect(response.body.record.desc).toBeUndefined(); expect(response.body.record.price).toBeUndefined(); expect(response.body.record.category_id).toBeUndefined(); expect(response.body.attributesClauseModified).toBe(true); expect(response.body.preResult).toEqual({ step: 2, attributesModified: true, }); }); test('single post hook mutations persist in returned payload', async () => { const { sequelize: s, Item } = await build(); sequelize = s; // Seed one item const item = await Item.create({ external_id: 'mut-payload', name: 'Original Name', status: 'active', }); // New app with single route whose post hook mutates the payload const app = express(); app.use(bodyParser.json()); app.use( '/items', single(Item, { post: async (ctx) => { // mutate nested record and add a top-level field if (ctx && ctx.payload && ctx.payload.record) { ctx.payload.record.name = 'Name From Post Hook'; ctx.payload.record.extra = 'added-by-hook'; } ctx.payload.mutated = true; }, }) ); const res = await request(app).get(`/items/${item.id}`); expect(res.status).toBe(200); expect(res.body.success).toBe(true); // Ensure the mutation happened on the same payload object that is returned expect(res.body.record.name).toBe('Name From Post Hook'); expect(res.body.record.extra).toBe('added-by-hook'); expect(res.body.mutated).toBe(true); }); });