UNPKG

apialize

Version:

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

282 lines (246 loc) 9.19 kB
const express = require('express'); const bodyParser = require('body-parser'); const request = require('supertest'); const { Sequelize, DataTypes } = require('sequelize'); const { create, single, patch } = require('../src'); async function build({ mountSingle = true, mountCreate = true, patchOptions = {}, 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 }, user_id: { type: DataTypes.INTEGER, allowNull: true }, parent_id: { type: DataTypes.INTEGER, allowNull: true }, }, { tableName: 'patch_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: patchOptions.id_mapping || 'id' }) ); app.use('/items', patch(Item, patchOptions, modelOptions)); return { sequelize, Item, app }; } async function getRecord(Item, where) { const row = await Item.findOne({ where }); return row && row.get({ plain: true }); } describe('patch operation: comprehensive options coverage', () => { let sequelize; afterEach(async () => { if (sequelize) { await sequelize.close(); sequelize = null; } }); test('partial update only changes provided fields and ignores id mapping field', async () => { const ctx = await build(); sequelize = ctx.sequelize; const { Item, app } = ctx; const created = await request(app) .post('/items') .send({ external_id: 'p-1', name: 'Alpha', desc: 'x', flag: false }); expect(created.status).toBe(201); const id = created.body.id; // Attempt to patch name and include id/external_id in body; mapping field must be ignored const res = await request(app) .patch(`/items/${id}`) .send({ name: 'Beta', id: 999, external_id: 'nope' }); expect(res.status).toBe(200); expect(res.body).toMatchObject({ success: true, id: String(id) }); const rec = await getRecord(Item, { id }); expect(rec.name).toBe('Beta'); expect(rec.desc).toBe('x'); expect(rec.flag).toBe(false); // external_id is not the id_mapping here, so it should be updated expect(rec.external_id).toBe('nope'); }); test('id_mapping: external_id returns param id and updates partially', async () => { const ctx = await build({ patchOptions: { id_mapping: 'external_id' } }); sequelize = ctx.sequelize; const { Item, app } = ctx; await request(app) .post('/items') .send({ external_id: 'ext-1', name: 'A', desc: 'x' }); const res = await request(app) .patch(`/items/ext-1`) .send({ desc: 'patched' }); expect(res.status).toBe(200); expect(res.body).toMatchObject({ success: true, id: 'ext-1' }); const rec = await getRecord(Item, { external_id: 'ext-1' }); expect(rec.name).toBe('A'); expect(rec.desc).toBe('patched'); }); test('empty patch responds success when record exists, otherwise 404', async () => { const ctx = await build(); sequelize = ctx.sequelize; const { Item, app } = ctx; const created = await request(app) .post('/items') .send({ external_id: 'p-2', name: 'X' }); const id = created.body.id; const ok = await request(app).patch(`/items/${id}`).send({}); expect(ok.status).toBe(200); expect(ok.body).toMatchObject({ success: true, id: String(id) }); const miss = await request(app).patch(`/items/9999`).send({}); expect(miss.status).toBe(404); }); test('ownership scoping via query filters: mismatch returns 404, match updates', async () => { const ctx = await build(); sequelize = ctx.sequelize; const { Item, app } = ctx; const r1 = await request(app) .post('/items') .send({ external_id: 'u1', name: 'N1', user_id: 1 }); const r2 = await request(app) .post('/items') .send({ external_id: 'u2', name: 'N2', user_id: 2 }); const miss = await request(app) .patch(`/items/${r2.body.id}?user_id=1`) .send({ name: 'no' }); expect(miss.status).toBe(404); const ok = await request(app) .patch(`/items/${r1.body.id}?user_id=1`) .send({ name: 'yes' }); expect(ok.status).toBe(200); const rec = await getRecord(Item, { id: r1.body.id }); expect(rec.name).toBe('yes'); }); test('middleware can enforce parent scoping and modify values', async () => { const scope = (req, _res, next) => { req.apialize.applyWhere({ parent_id: 8 }); next(); }; const override = (req, _res, next) => { req.apialize.values = { ...(req.apialize.values || {}), desc: 'locked' }; next(); }; const ctx = await build({ patchOptions: { middleware: [scope, override] }, }); sequelize = ctx.sequelize; const { Item, app } = ctx; const t1 = await request(app) .post('/items') .send({ external_id: 't1', name: 'A', parent_id: 8 }); await request(app) .post('/items') .send({ external_id: 't2', name: 'B', parent_id: 9 }); const ok = await request(app) .patch(`/items/${t1.body.id}`) .send({ name: 'A+' }); expect(ok.status).toBe(200); const miss = await request(app) .patch(`/items/${t1.body.id}`) .send({ name: 'A++' }); expect(miss.status).toBe(200); // Still scoped to parent 8 by middleware; same record const rec = await getRecord(Item, { id: t1.body.id }); expect(rec.name).toBe('A++'); expect(rec.desc).toBe('locked'); }); test('404 when record not found with custom id_mapping', async () => { const ctx = await build({ patchOptions: { id_mapping: 'external_id' } }); sequelize = ctx.sequelize; const { app } = ctx; const res = await request(app) .patch(`/items/not-there`) .send({ name: 'nope' }); expect(res.status).toBe(404); }); test('pre/post hooks: transaction present and payload mutated (patch)', async () => { const ctx = await build({ patchOptions: { 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-p1', name: 'A' }); const id = created.body.id; const res = await request(app).patch(`/items/${id}`).send({ name: 'B' }); expect(res.status).toBe(200); expect(res.body.success).toBe(true); expect(res.body.hook).toBe('post'); }); test('array pre/post hooks: multiple functions execute in order (patch)', async () => { const executionOrder = []; const ctx = await build({ patchOptions: { 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-p1', name: 'ArrayPatchTest' }); expect(created.status).toBe(201); const id = created.body.id; // Then patch it with array hooks const patched = await request(app) .patch(`/items/${id}`) .send({ name: 'ArrayPatchTestPatched' }); expect(patched.status).toBe(200); expect(patched.body.success).toBe(true); expect(patched.body.hook1).toBe('executed'); expect(patched.body.hook2).toBe('also-executed'); expect(executionOrder).toEqual(['pre1', 'pre2', 'pre3', 'post1', 'post2']); }); });