apialize
Version:
Turn a database model into a production ready REST(ish) CRUD API in a few lines.
351 lines (305 loc) • 11.6 kB
JavaScript
const express = require('express');
const bodyParser = require('body-parser');
const request = require('supertest');
const { Sequelize, DataTypes } = require('sequelize');
const { create, single, update } = require('../src');
// Build a fresh app + model per test to isolate middleware/params
async function build({
mountSingle = true,
mountCreate = true,
updateOptions = {},
modelOptions = {},
} = {}) {
const sequelize = new Sequelize('sqlite::memory:', { logging: 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 },
flag: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true },
category: {
type: DataTypes.STRING(32),
allowNull: true,
defaultValue: 'uncat',
},
user_id: { type: DataTypes.INTEGER, allowNull: true },
parent_id: { type: DataTypes.INTEGER, allowNull: true },
},
{ tableName: 'update_items', timestamps: false }
);
await sequelize.sync({ force: true });
const app = express();
app.use(bodyParser.json());
if (mountCreate) app.use('/items', create(Item));
if (mountSingle)
app.use(
'/items',
single(Item, { id_mapping: updateOptions.id_mapping || 'id' })
);
app.use('/items', update(Item, updateOptions, modelOptions));
return { sequelize, Item, app };
}
async function seed(Item, rows) {
await Item.bulkCreate(rows);
}
async function getRecord(Item, where) {
const row = await Item.findOne({ where });
return row && row.get({ plain: true });
}
describe('update operation: comprehensive options coverage', () => {
let sequelize;
afterEach(async () => {
if (sequelize) {
await sequelize.close();
sequelize = null;
}
});
test('default id mapping with full-replace semantics (unspecified -> default/null)', async () => {
const ctx = await build();
sequelize = ctx.sequelize;
const { Item, app } = ctx;
// Create an item with non-defaults
const created = await request(app).post('/items').send({
external_id: 'ex-1',
name: 'Alpha',
desc: 'has-desc',
flag: false, // will be reset to default (true) by PUT when unspecified
category: 'catA',
user_id: 1,
parent_id: 10,
});
expect(created.status).toBe(201);
const id = created.body.id;
// PUT with only name provided would null external_id (NOT NULL). Include external_id to preserve it.
const put = await request(app)
.put(`/items/${id}`)
.send({ name: 'Beta', external_id: 'ex-1' });
expect(put.status).toBe(200);
expect(put.body).toMatchObject({ success: true });
const rec = await getRecord(Item, { id });
expect(rec.name).toBe('Beta');
expect(rec.desc).toBeNull(); // no default -> null
expect(rec.flag).toBe(true); // defaultValue restored
expect(rec.category).toBe('uncat'); // defaultValue restored
expect(rec.external_id).toBe('ex-1'); // unchanged
});
test('cannot change numeric id even if provided in body (default id)', async () => {
const ctx = await build();
sequelize = ctx.sequelize;
const { Item, app } = ctx;
const created = await request(app)
.post('/items')
.send({ external_id: 'ex-2', name: 'A' });
expect(created.status).toBe(201);
const id = created.body.id;
// Try to change numeric id in body; it should be ignored/overridden. Preserve external_id to satisfy NOT NULL.
const put = await request(app)
.put(`/items/${id}`)
.send({ name: 'B', id: 999, external_id: 'ex-2' });
expect(put.status).toBe(200);
const rec = await getRecord(Item, { id });
expect(rec.id).toBe(id);
expect(rec.external_id).toBe('ex-2');
expect(rec.name).toBe('B');
});
test('id_mapping: external_id with full-replace semantics and mapping immutability', async () => {
const ctx = await build({ updateOptions: { id_mapping: 'external_id' } });
sequelize = ctx.sequelize;
const { Item, app } = ctx;
const created = await request(app)
.post('/items')
.send({ external_id: 'ext-A', name: 'A', desc: 'x', category: 'c' });
expect(created.status).toBe(201);
const put = await request(app)
.put(`/items/ext-A`)
.send({ name: 'B', external_id: 'ext-B' }); // attempt to change mapping should be ignored
expect(put.status).toBe(200);
const rec = await getRecord(Item, { external_id: 'ext-A' });
expect(rec).toBeTruthy();
expect(rec.name).toBe('B');
expect(rec.external_id).toBe('ext-A'); // unchanged
expect(rec.desc).toBeNull(); // unspecified -> null
expect(rec.category).toBe('uncat'); // reset to default
});
test('ownership scoping via query filters: mismatch returns 404, match updates (preserve scoped fields)', async () => {
const ctx = await build();
sequelize = ctx.sequelize;
const { Item, app } = ctx;
const r1 = await request(app)
.post('/items')
.send({ external_id: 'o1', name: 'N1', user_id: 1 });
const r2 = await request(app)
.post('/items')
.send({ external_id: 'o2', name: 'N2', user_id: 2 });
expect(r1.status).toBe(201);
expect(r2.status).toBe(201);
const id2 = r2.body.id;
const id1 = r1.body.id;
// Attempt to update record 2 while scoping to user_id=1 -> not found
const miss = await request(app)
.put(`/items/${id2}?user_id=1`)
.send({ name: 'NOOP' });
expect(miss.status).toBe(404);
// Correct scope updates
const ok = await request(app)
.put(`/items/${id1}?user_id=1`)
.send({ name: 'Scoped', user_id: '1', external_id: 'o1' });
expect(ok.status).toBe(200);
const rec = await getRecord(Item, { id: id1 });
expect(rec.name).toBe('Scoped');
});
test('middleware can enforce parent scoping via req.apialize.options.where (preserve scoped fields)', async () => {
const parentMiddleware = (req, _res, next) => {
req.apialize.applyWhere({ parent_id: 123 });
next();
};
const ctx = await build({
updateOptions: { middleware: [parentMiddleware] },
});
sequelize = ctx.sequelize;
const { Item, app } = ctx;
const t1 = await request(app)
.post('/items')
.send({ external_id: 't1', name: 'T1', parent_id: 123 });
const t2 = await request(app)
.post('/items')
.send({ external_id: 't2', name: 'T2', parent_id: 999 });
expect(t1.status).toBe(201);
expect(t2.status).toBe(201);
// Correct parent updates
const ok = await request(app)
.put(`/items/${t1.body.id}`)
.send({ name: 'T1-upd', parent_id: 123, external_id: 't1' });
expect(ok.status).toBe(200);
// Wrong parent -> 404
const miss = await request(app)
.put(`/items/${t2.body.id}`)
.send({ name: 'T2-upd', parent_id: 123, external_id: 't2' });
expect(miss.status).toBe(404);
});
test('middleware can modify values prior to update (category locked)', async () => {
const lockCategory = (req, _res, next) => {
req.apialize.values = {
...(req.apialize.values || {}),
category: 'locked',
};
next();
};
const ctx = await build({ updateOptions: { middleware: [lockCategory] } });
sequelize = ctx.sequelize;
const { Item, app } = ctx;
const created = await request(app)
.post('/items')
.send({ external_id: 'val-1', name: 'A', category: 'open' });
expect(created.status).toBe(201);
const put = await request(app)
.put(`/items/${created.body.id}`)
.send({ name: 'B', category: 'should-be-ignored', external_id: 'val-1' });
expect(put.status).toBe(200);
const rec = await getRecord(Item, { id: created.body.id });
expect(rec.name).toBe('B');
expect(rec.category).toBe('locked'); // middleware override applied
});
test('404 when record not found (default id mapping)', async () => {
const ctx = await build();
sequelize = ctx.sequelize;
const { app } = ctx;
const put = await request(app).put(`/items/9999`).send({ name: 'Nope' });
expect(put.status).toBe(404);
});
test('id_mapping external_id: 404 when record not found', async () => {
const ctx = await build({ updateOptions: { id_mapping: 'external_id' } });
sequelize = ctx.sequelize;
const { app } = ctx;
const put = await request(app)
.put(`/items/missing-exid`)
.send({ name: 'Nope' });
expect(put.status).toBe(404);
});
test('pre/post hooks: transaction present and payload mutated', async () => {
const ctx = await build({
updateOptions: {
pre: async (context) => {
expect(context.transaction).toBeTruthy();
expect(typeof context.transaction.commit).toBe('function');
return { ok: true };
},
post: async (context) => {
expect(context.preResult).toEqual({ ok: true });
context.payload.hook = 'post';
},
},
});
sequelize = ctx.sequelize;
const { Item, app } = ctx;
const created = await request(app)
.post('/items')
.send({ external_id: 'hook-1', name: 'A' });
expect(created.status).toBe(201);
const id = created.body.id;
const put = await request(app)
.put(`/items/${id}`)
.send({ name: 'B', external_id: 'hook-1' });
expect(put.status).toBe(200);
expect(put.body.success).toBe(true);
expect(put.body.hook).toBe('post');
});
test('array pre/post hooks: multiple functions execute in order (update)', async () => {
const executionOrder = [];
const ctx = await build({
updateOptions: {
pre: [
async (context) => {
executionOrder.push('pre1');
expect(context.transaction).toBeTruthy();
return { step: 1 };
},
async (context) => {
executionOrder.push('pre2');
expect(context.transaction).toBeTruthy();
return { step: 2 };
},
async (context) => {
executionOrder.push('pre3');
expect(context.transaction).toBeTruthy();
return { step: 3, finalPre: true };
},
],
post: [
async (context) => {
executionOrder.push('post1');
expect(context.preResult).toEqual({ step: 3, finalPre: true });
context.payload.hook1 = 'executed';
},
async (context) => {
executionOrder.push('post2');
expect(context.payload.hook1).toBe('executed');
context.payload.hook2 = 'also-executed';
},
],
},
});
sequelize = ctx.sequelize;
const { Item, app } = ctx;
// First create an item
const created = await request(app)
.post('/items')
.send({ external_id: 'array-hooks-u1', name: 'ArrayUpdateTest' });
expect(created.status).toBe(201);
// Then update it with array hooks
const updated = await request(app)
.put(`/items/${created.body.id}`)
.send({ name: 'ArrayUpdateTestUpdated', external_id: 'array-hooks-u1' });
expect(updated.status).toBe(200);
expect(updated.body.success).toBe(true);
expect(updated.body.hook1).toBe('executed');
expect(updated.body.hook2).toBe('also-executed');
expect(executionOrder).toEqual(['pre1', 'pre2', 'pre3', 'post1', 'post2']);
});
});