UNPKG

apialize

Version:

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

675 lines (577 loc) 20.7 kB
const express = require('express'); const bodyParser = require('body-parser'); const request = require('supertest'); const { Sequelize, DataTypes } = require('sequelize'); const { search, list } = require('../src'); describe('Foreign Key Mapping with relation_id_mapping', () => { let sequelize; afterEach(async () => { if (sequelize) { await sequelize.close(); sequelize = null; } }); describe('basic foreign key mapping', () => { let Artist; let Album; let Song; beforeEach(async () => { sequelize = new Sequelize('sqlite::memory:', { logging: false }); // Artist model with both id and external_id Artist = sequelize.define( 'Artist', { id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true, }, external_id: { type: DataTypes.STRING(50), unique: true, allowNull: false, }, name: { type: DataTypes.STRING(100), allowNull: false }, }, { tableName: 'artists', timestamps: false } ); // Album model with both id and external_id Album = sequelize.define( 'Album', { id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true, }, external_id: { type: DataTypes.STRING(50), unique: true, allowNull: false, }, title: { type: DataTypes.STRING(100), allowNull: false }, artist_id: { type: DataTypes.INTEGER, allowNull: false }, }, { tableName: 'albums', timestamps: false } ); // Song model with both id and external_id Song = sequelize.define( 'Song', { id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true, }, external_id: { type: DataTypes.STRING(50), unique: true, allowNull: false, }, title: { type: DataTypes.STRING(100), allowNull: false }, album_id: { type: DataTypes.INTEGER, allowNull: false }, artist_id: { type: DataTypes.INTEGER, allowNull: false }, }, { tableName: 'songs', timestamps: false } ); // Set up associations Album.belongsTo(Artist, { as: 'artist', foreignKey: 'artist_id' }); Artist.hasMany(Album, { as: 'albums', foreignKey: 'artist_id' }); Song.belongsTo(Album, { as: 'album', foreignKey: 'album_id' }); Song.belongsTo(Artist, { as: 'artist', foreignKey: 'artist_id' }); Album.hasMany(Song, { as: 'songs', foreignKey: 'album_id' }); Artist.hasMany(Song, { as: 'songs', foreignKey: 'artist_id' }); await sequelize.sync({ force: true }); // Seed test data const [artist1, artist2] = await Artist.bulkCreate( [ { external_id: 'artist-beethoven', name: 'Ludwig van Beethoven' }, { external_id: 'artist-mozart', name: 'Wolfgang Amadeus Mozart' }, ], { returning: true } ); const [album1, album2, album3] = await Album.bulkCreate( [ { external_id: 'album-sym5', title: 'Symphony No. 5', artist_id: artist1.id, }, { external_id: 'album-sym9', title: 'Symphony No. 9', artist_id: artist1.id, }, { external_id: 'album-req', title: 'Requiem', artist_id: artist2.id }, ], { returning: true } ); await Song.bulkCreate([ { external_id: 'song-sym5-1', title: 'Symphony No. 5 - Movement 1', album_id: album1.id, artist_id: artist1.id, }, { external_id: 'song-sym5-2', title: 'Symphony No. 5 - Movement 2', album_id: album1.id, artist_id: artist1.id, }, { external_id: 'song-sym9-1', title: 'Symphony No. 9 - Ode to Joy', album_id: album2.id, artist_id: artist1.id, }, { external_id: 'song-req-1', title: 'Requiem - Kyrie', album_id: album3.id, artist_id: artist2.id, }, ]); }); describe('list endpoint foreign key mapping', () => { test('should replace artist_id and album_id with external IDs in response', async () => { const app = express(); app.use(bodyParser.json()); // Configure list with relation_id_mapping for foreign key mapping app.use( '/songs', list(Song, { relation_id_mapping: [ { model: Artist, id_field: 'external_id' }, { model: Album, id_field: 'external_id' }, ], }) ); const response = await request(app).get('/songs'); expect(response.status).toBe(200); expect(response.body.success).toBe(true); expect(response.body.data).toHaveLength(4); // Check that foreign key values are replaced with external IDs const songs = response.body.data; // Find a Beethoven song const beethovenSong = songs.find((s) => s.title.includes('Symphony No. 5') ); expect(beethovenSong).toBeDefined(); expect(beethovenSong.artist_id).toBe('artist-beethoven'); // Should be external_id, not internal ID expect(beethovenSong.album_id).toBe('album-sym5'); // Should be external_id, not internal ID // Find a Mozart song const mozartSong = songs.find((s) => s.title.includes('Requiem')); expect(mozartSong).toBeDefined(); expect(mozartSong.artist_id).toBe('artist-mozart'); // Should be external_id, not internal ID expect(mozartSong.album_id).toBe('album-req'); // Should be external_id, not internal ID }); test('should work with id_mapping for root model combined with FK mapping', async () => { const app = express(); app.use(bodyParser.json()); // Configure both root id_mapping and relation_id_mapping app.use( '/songs', list(Song, { id_mapping: 'external_id', // Root model uses external_id for id relation_id_mapping: [ { model: Artist, id_field: 'external_id' }, { model: Album, id_field: 'external_id' }, ], }) ); const response = await request(app).get('/songs'); expect(response.status).toBe(200); expect(response.body.success).toBe(true); expect(response.body.data).toHaveLength(4); const song = response.body.data[0]; // Root model ID should use external_id due to id_mapping expect(song.id).toMatch(/^song-/); // Foreign keys should also be mapped expect(song.artist_id).toMatch(/^artist-/); expect(song.album_id).toMatch(/^album-/); }); test('should only map configured foreign keys', async () => { const app = express(); app.use(bodyParser.json()); // Only configure Artist mapping, not Album app.use( '/songs', list(Song, { relation_id_mapping: [ { model: Artist, id_field: 'external_id' }, // Album intentionally omitted ], }) ); const response = await request(app).get('/songs'); expect(response.status).toBe(200); expect(response.body.success).toBe(true); expect(response.body.data).toHaveLength(4); const song = response.body.data[0]; // artist_id should be mapped to external_id expect(song.artist_id).toMatch(/^artist-/); // album_id should remain as internal ID (numeric) expect(typeof song.album_id).toBe('number'); }); }); describe('search endpoint foreign key mapping', () => { test('should replace artist_id and album_id with external IDs in search response', async () => { const app = express(); app.use(bodyParser.json()); app.use( '/songs', search(Song, { relation_id_mapping: [ { model: Artist, id_field: 'external_id' }, { model: Album, id_field: 'external_id' }, ], }) ); const response = await request(app).post('/songs/search').send({ filtering: {}, // Get all songs }); expect(response.status).toBe(200); expect(response.body.success).toBe(true); expect(response.body.data).toHaveLength(4); // Check foreign key mapping const song = response.body.data[0]; expect(song.artist_id).toMatch(/^artist-/); expect(song.album_id).toMatch(/^album-/); }); test('should work with filtering and FK mapping together', async () => { const app = express(); app.use(bodyParser.json()); app.use( '/songs', search(Song, { relation_id_mapping: [ { model: Artist, id_field: 'external_id' }, { model: Album, id_field: 'external_id' }, ], }) ); // Filter by title to get specific song const response = await request(app) .post('/songs/search') .send({ filtering: { title: { contains: 'Symphony No. 5' }, }, }); expect(response.status).toBe(200); expect(response.body.success).toBe(true); expect(response.body.data).toHaveLength(2); // Two movements // All returned songs should have FK mapping applied for (const song of response.body.data) { expect(song.artist_id).toBe('artist-beethoven'); expect(song.album_id).toBe('album-sym5'); } }); }); describe('error handling', () => { test('should handle missing models gracefully', async () => { const app = express(); app.use(bodyParser.json()); app.use( '/songs', list(Song, { relation_id_mapping: [ { model: null, id_field: 'external_id' }, // Invalid mapping { model: Artist, id_field: 'external_id' }, ], }) ); const response = await request(app).get('/songs'); expect(response.status).toBe(200); expect(response.body.success).toBe(true); // Should still work, just skip invalid mappings expect(response.body.data).toHaveLength(4); }); test('should throw error for invalid id_field', async () => { const app = express(); app.use(bodyParser.json()); app.use( '/songs', list(Song, { relation_id_mapping: [ { model: Artist, id_field: 'nonexistent_field' }, ], }) ); const response = await request(app).get('/songs'); expect(response.status).toBe(500); // Should return error since invalid id_field will cause lookup to fail }); test('should work when no foreign keys match the mapping patterns', async () => { const app = express(); app.use(bodyParser.json()); // Test with a model that doesn't have foreign keys matching our patterns app.use( '/artists', list(Artist, { relation_id_mapping: [{ model: Album, id_field: 'external_id' }], }) ); const response = await request(app).get('/artists'); expect(response.status).toBe(200); expect(response.body.success).toBe(true); expect(response.body.data).toHaveLength(2); // No foreign keys to map, should work normally }); }); }); describe('enhanced foreign key mapping with Sequelize associations', () => { test('should handle mixed association-based and pattern-based foreign keys', async () => { sequelize = new Sequelize('sqlite::memory:', { logging: false }); const Artist = sequelize.define( 'Artist', { id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true, }, external_id: { type: DataTypes.STRING(50), unique: true, allowNull: false, }, name: { type: DataTypes.STRING(100), allowNull: false }, }, { tableName: 'artists', timestamps: false } ); const Label = sequelize.define( 'Label', { id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true, }, external_id: { type: DataTypes.STRING(50), unique: true, allowNull: false, }, name: { type: DataTypes.STRING(100), allowNull: false }, }, { tableName: 'labels', timestamps: false } ); const Album = sequelize.define( 'Album', { id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true, }, external_id: { type: DataTypes.STRING(50), unique: true, allowNull: false, }, title: { type: DataTypes.STRING(100), allowNull: false }, main_artist_id: { type: DataTypes.INTEGER, allowNull: false }, // Association-based producer_id: { type: DataTypes.INTEGER, allowNull: false }, // Association-based label_id: { type: DataTypes.INTEGER, allowNull: false }, // Pattern-based }, { tableName: 'albums', timestamps: false } ); // Set up associations with custom foreign key names Album.belongsTo(Artist, { as: 'mainArtist', foreignKey: 'main_artist_id', }); Album.belongsTo(Artist, { as: 'producer', foreignKey: 'producer_id' }); // No association for label_id - should fall back to pattern matching await sequelize.sync({ force: true }); // Seed test data const artist = await Artist.create({ external_id: 'artist-beatles', name: 'The Beatles', }); const producer = await Artist.create({ external_id: 'producer-martin', name: 'George Martin', }); const label = await Label.create({ external_id: 'label-apple', name: 'Apple Records', }); const album = await Album.create({ external_id: 'album-abbey-road', title: 'Abbey Road', main_artist_id: artist.id, producer_id: producer.id, label_id: label.id, }); const app = express(); app.use(bodyParser.json()); // Configure relation_id_mapping for all models app.use( '/albums', list(Album, { id_mapping: 'external_id', // Root model mapping relation_id_mapping: [ { model: Artist, id_field: 'external_id' }, { model: Label, id_field: 'external_id' }, ], }) ); const response = await request(app).get('/albums'); expect(response.status).toBe(200); expect(response.body.success).toBe(true); expect(response.body.data).toHaveLength(1); const albumData = response.body.data[0]; // Association-based mappings should work expect(albumData.main_artist_id).toBe('artist-beatles'); expect(albumData.producer_id).toBe('producer-martin'); // Pattern-based mapping should also work as fallback expect(albumData.label_id).toBe('label-apple'); // Basic id mapping should still work expect(albumData.id).toBe('album-abbey-road'); }); test('should work with search endpoint as well', async () => { sequelize = new Sequelize('sqlite::memory:', { logging: false }); const Artist = sequelize.define( 'Artist', { id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true, }, external_id: { type: DataTypes.STRING(50), unique: true, allowNull: false, }, name: { type: DataTypes.STRING(100), allowNull: false }, }, { tableName: 'artists', timestamps: false } ); const Song = sequelize.define( 'Song', { id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true, }, external_id: { type: DataTypes.STRING(50), unique: true, allowNull: false, }, title: { type: DataTypes.STRING(100), allowNull: false }, lead_vocalist_id: { type: DataTypes.INTEGER, allowNull: false }, // Custom association name }, { tableName: 'songs', timestamps: false } ); // Association with custom foreign key Song.belongsTo(Artist, { as: 'leadVocalist', foreignKey: 'lead_vocalist_id', }); await sequelize.sync({ force: true }); const artist = await Artist.create({ external_id: 'vocalist-lennon', name: 'John Lennon', }); const song = await Song.create({ external_id: 'song-imagine', title: 'Imagine', lead_vocalist_id: artist.id, }); const app = express(); app.use(bodyParser.json()); app.use( '/songs', search(Song, { id_mapping: 'external_id', // Root model mapping relation_id_mapping: [{ model: Artist, id_field: 'external_id' }], }) ); const response = await request(app) .post('/songs/search') .send({ filtering: { title: 'Imagine' }, }); expect(response.status).toBe(200); expect(response.body.success).toBe(true); expect(response.body.data).toHaveLength(1); const songData = response.body.data[0]; // Association-based foreign key should be mapped expect(songData.lead_vocalist_id).toBe('vocalist-lennon'); expect(songData.lead_vocalist_id).not.toBe(artist.id); // Root id mapping should work expect(songData.id).toBe('song-imagine'); }); test('should handle missing associations gracefully with fallback to patterns', async () => { sequelize = new Sequelize('sqlite::memory:', { logging: false }); const Category = sequelize.define( 'Category', { id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true, }, external_id: { type: DataTypes.STRING(50), unique: true, allowNull: false, }, name: { type: DataTypes.STRING(100), allowNull: false }, }, { tableName: 'categories', timestamps: false } ); const Product = sequelize.define( 'Product', { id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true, }, external_id: { type: DataTypes.STRING(50), unique: true, allowNull: false, }, name: { type: DataTypes.STRING(100), allowNull: false }, category_id: { type: DataTypes.INTEGER, allowNull: false }, // No association defined }, { tableName: 'products', timestamps: false } ); // Intentionally NO associations defined - should use pattern matching await sequelize.sync({ force: true }); const category = await Category.create({ external_id: 'cat-electronics', name: 'Electronics', }); const product = await Product.create({ external_id: 'prod-laptop', name: 'Gaming Laptop', category_id: category.id, }); const app = express(); app.use(bodyParser.json()); app.use( '/products', list(Product, { relation_id_mapping: [{ model: Category, id_field: 'external_id' }], }) ); const response = await request(app).get('/products'); expect(response.status).toBe(200); expect(response.body.success).toBe(true); expect(response.body.data).toHaveLength(1); const productData = response.body.data[0]; // Should fall back to pattern-based detection expect(productData.category_id).toBe('cat-electronics'); expect(productData.category_id).not.toBe(category.id); }); }); });