From 657cb075ee2d7ede686b7b9ec799786021ae6ab8 Mon Sep 17 00:00:00 2001 From: Jozsef Kiraly <371847+fonix232@users.noreply.github.com> Date: Thu, 27 Nov 2025 11:54:43 +0000 Subject: [PATCH 1/2] feat: Add AudioClip data model Also: migration and test coverage --- server/migrations/v2.31.0-audio-clips.js | 171 +++++++ server/models/AudioClip.js | 329 +++++++++++++ .../migrations/v2.31.0-audio-clips.test.js | 284 ++++++++++++ test/server/models/AudioClip.test.js | 435 ++++++++++++++++++ 4 files changed, 1219 insertions(+) create mode 100644 server/migrations/v2.31.0-audio-clips.js create mode 100644 server/models/AudioClip.js create mode 100644 test/server/migrations/v2.31.0-audio-clips.test.js create mode 100644 test/server/models/AudioClip.test.js diff --git a/server/migrations/v2.31.0-audio-clips.js b/server/migrations/v2.31.0-audio-clips.js new file mode 100644 index 000000000..217f67b29 --- /dev/null +++ b/server/migrations/v2.31.0-audio-clips.js @@ -0,0 +1,171 @@ +const uuidv4 = require('uuid').v4 + +/** + * @typedef MigrationContext + * @property {import('sequelize').QueryInterface} queryInterface - a sequelize QueryInterface object. + * @property {import('../Logger')} logger - a Logger object. + * + * @typedef MigrationOptions + * @property {MigrationContext} context - an object containing the migration context. + */ + +/** + * This upward migration creates the audioClips table and migrates existing bookmarks to clips. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function up({ context: { queryInterface, logger } }) { + logger.info('[2.31.0 migration] UPGRADE BEGIN: 2.31.0-audio-clips') + + const DataTypes = queryInterface.sequelize.Sequelize.DataTypes + + // Create audioClips table + logger.info('[2.31.0 migration] Creating audioClips table') + await queryInterface.createTable('audioClips', { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + userId: { + type: DataTypes.UUID, + allowNull: false, + references: { + model: 'users', + key: 'id' + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE' + }, + libraryItemId: { + type: DataTypes.UUID, + allowNull: false, + references: { + model: 'libraryItems', + key: 'id' + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE' + }, + episodeId: { + type: DataTypes.UUID, + allowNull: true + }, + startTime: { + type: DataTypes.REAL, + allowNull: false + }, + endTime: { + type: DataTypes.REAL, + allowNull: false + }, + title: { + type: DataTypes.STRING, + allowNull: false, + defaultValue: '' + }, + note: { + type: DataTypes.TEXT, + allowNull: true + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false + }, + updatedAt: { + type: DataTypes.DATE, + allowNull: false + } + }) + + // Create indexes + logger.info('[2.31.0 migration] Creating indexes on audioClips table') + await queryInterface.addIndex('audioClips', ['userId'], { + name: 'audio_clips_user_id' + }) + await queryInterface.addIndex('audioClips', ['libraryItemId'], { + name: 'audio_clips_library_item_id' + }) + await queryInterface.addIndex('audioClips', ['startTime'], { + name: 'audio_clips_start_time' + }) + await queryInterface.addIndex('audioClips', ['userId', 'libraryItemId'], { + name: 'audio_clips_user_library_item' + }) + + // Migrate existing bookmarks to clips + logger.info('[2.31.0 migration] Migrating bookmarks to clips') + + const [users] = await queryInterface.sequelize.query('SELECT id, bookmarks FROM users WHERE bookmarks IS NOT NULL AND bookmarks != "[]"') + + let totalMigrated = 0 + const now = new Date() + + for (const user of users) { + try { + const bookmarks = JSON.parse(user.bookmarks || '[]') + + if (!Array.isArray(bookmarks) || bookmarks.length === 0) { + continue + } + + logger.info(`[2.31.0 migration] Migrating ${bookmarks.length} bookmarks for user ${user.id}`) + + for (const bookmark of bookmarks) { + if (!bookmark.libraryItemId || typeof bookmark.time !== 'number') { + logger.warn(`[2.31.0 migration] Skipping invalid bookmark for user ${user.id}:`, bookmark) + continue + } + + // Create clip with 5-second default duration + const clipId = uuidv4() + const startTime = Number(bookmark.time) + const endTime = startTime + 10 // Default 10-second clip + const title = bookmark.title || 'Migrated Bookmark' + const createdAt = bookmark.createdAt ? new Date(bookmark.createdAt) : now + + await queryInterface.sequelize.query( + `INSERT INTO audioClips (id, userId, libraryItemId, episodeId, startTime, endTime, title, note, createdAt, updatedAt) + VALUES (?, ?, ?, NULL, ?, ?, ?, '', ?, ?)`, + { + replacements: [clipId, user.id, bookmark.libraryItemId, startTime, endTime, title, createdAt, now] + } + ) + + totalMigrated++ + } + } catch (error) { + logger.error(`[2.31.0 migration] Error migrating bookmarks for user ${user.id}:`, error) + } + } + + logger.info(`[2.31.0 migration] Successfully migrated ${totalMigrated} bookmarks to clips`) + logger.info('[2.31.0 migration] UPGRADE END: 2.31.0-audio-clips') +} + +/** + * This downward migration removes the audioClips table. + * Note: This does not restore bookmarks. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function down({ context: { queryInterface, logger } }) { + logger.info('[2.31.0 migration] DOWNGRADE BEGIN: 2.31.0-audio-clips') + + // Drop indexes + logger.info('[2.31.0 migration] Dropping indexes') + await queryInterface.removeIndex('audioClips', 'audio_clips_user_id') + await queryInterface.removeIndex('audioClips', 'audio_clips_library_item_id') + await queryInterface.removeIndex('audioClips', 'audio_clips_start_time') + await queryInterface.removeIndex('audioClips', 'audio_clips_user_library_item') + + // Drop table + logger.info('[2.31.0 migration] Dropping audioClips table') + await queryInterface.dropTable('audioClips') + + logger.info('[2.31.0 migration] DOWNGRADE END: 2.31.0-audio-clips') +} + +module.exports = { up, down } diff --git a/server/models/AudioClip.js b/server/models/AudioClip.js new file mode 100644 index 000000000..b0df959d8 --- /dev/null +++ b/server/models/AudioClip.js @@ -0,0 +1,329 @@ +const { DataTypes, Model } = require('sequelize') +const Logger = require('../Logger') + +/** + * @typedef AudioClipObject + * @property {UUIDV4} id + * @property {UUIDV4} userId + * @property {UUIDV4} libraryItemId + * @property {UUIDV4} [episodeId] + * @property {number} startTime + * @property {number} endTime + * @property {string} title + * @property {string} [note] + * @property {Date} createdAt + * @property {Date} updatedAt + */ + +class AudioClip extends Model { + constructor(values, options) { + super(values, options) + + /** @type {UUIDV4} */ + this.id + /** @type {UUIDV4} */ + this.userId + /** @type {UUIDV4} */ + this.libraryItemId + /** @type {UUIDV4} */ + this.episodeId + /** @type {number} */ + this.startTime + /** @type {number} */ + this.endTime + /** @type {string} */ + this.title + /** @type {string} */ + this.note + /** @type {Date} */ + this.createdAt + /** @type {Date} */ + this.updatedAt + } + + /** + * Initialize model + * @param {import('../Database').sequelize} sequelize + */ + static init(sequelize) { + super.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + userId: { + type: DataTypes.UUID, + allowNull: false + }, + libraryItemId: { + type: DataTypes.UUID, + allowNull: false + }, + episodeId: { + type: DataTypes.UUID, + allowNull: true + }, + startTime: { + type: DataTypes.REAL, + allowNull: false + }, + endTime: { + type: DataTypes.REAL, + allowNull: false + }, + title: { + type: DataTypes.STRING, + allowNull: false, + defaultValue: '' + }, + note: { + type: DataTypes.TEXT, + allowNull: true + } + }, + { + sequelize, + modelName: 'audioClip', + indexes: [ + { + name: 'audio_clips_user_id', + fields: ['userId'] + }, + { + name: 'audio_clips_library_item_id', + fields: ['libraryItemId'] + }, + { + name: 'audio_clips_start_time', + fields: ['startTime'] + }, + { + name: 'audio_clips_user_library_item', + fields: ['userId', 'libraryItemId'] + } + ] + } + ) + + const { user, libraryItem } = sequelize.models + + user.hasMany(AudioClip, { + foreignKey: 'userId', + onDelete: 'CASCADE' + }) + AudioClip.belongsTo(user, { + foreignKey: 'userId' + }) + + libraryItem.hasMany(AudioClip, { + foreignKey: 'libraryItemId', + onDelete: 'CASCADE' + }) + AudioClip.belongsTo(libraryItem, { + foreignKey: 'libraryItemId' + }) + } + + /** + * Get duration of clip in seconds + * @returns {number} + */ + getDuration() { + return this.endTime - this.startTime + } + + /** + * Check if time range is valid + * @returns {boolean} + */ + isValidTimeRange() { + return this.startTime >= 0 && this.endTime > this.startTime + } + + /** + * Create a new audio clip + * @param {string} userId + * @param {string} libraryItemId + * @param {number} startTime - Start time in seconds + * @param {number} endTime - End time in seconds + * @param {string} title + * @param {string} [note] + * @param {string} [episodeId] + * @returns {Promise} + */ + static async createClip(userId, libraryItemId, startTime, endTime, title, note, episodeId = null) { + // Validate time range + if (startTime < 0) { + throw new Error('Start time must be non-negative') + } + if (endTime <= startTime) { + throw new Error('End time must be greater than start time') + } + + // TODO: Validate against library item duration? + + // Warn if clip duration is very long (more than 10 minutes) + const duration = endTime - startTime + if (duration > 600) { + Logger.warn(`[AudioClip] Creating clip with long duration: ${duration}s`) + } + + try { + const clip = await this.create({ + userId, + libraryItemId, + episodeId, + startTime, + endTime, + title: title || '', + note: note || null + }) + + Logger.info(`[AudioClip] Created clip ${clip.id} for user ${userId}`) + return clip + } catch (error) { + Logger.error(`[AudioClip] Failed to create clip:`, error) + throw error + } + } + + /** + * Update an existing audio clip + * @param {string} clipId + * @param {Object} updates + * @param {number} [updates.startTime] + * @param {number} [updates.endTime] + * @param {string} [updates.title] + * @param {string} [updates.note] + * @returns {Promise} + */ + static async updateClip(clipId, updates) { + const clip = await this.findByPk(clipId) + if (!clip) { + throw new Error('Clip not found') + } + + // If updating time range, validate it + const newStartTime = updates.startTime !== undefined ? updates.startTime : clip.startTime + const newEndTime = updates.endTime !== undefined ? updates.endTime : clip.endTime + + if (newStartTime < 0) { + throw new Error('Start time must be non-negative') + } + if (newEndTime <= newStartTime) { + throw new Error('End time must be greater than start time') + } + + try { + await clip.update(updates) + Logger.info(`[AudioClip] Updated clip ${clipId}`) + return clip + } catch (error) { + Logger.error(`[AudioClip] Failed to update clip ${clipId}:`, error) + throw error + } + } + + /** + * Delete an audio clip + * @param {string} clipId + * @returns {Promise} + */ + static async deleteClip(clipId) { + try { + const deleted = await this.destroy({ + where: { id: clipId } + }) + if (deleted > 0) { + Logger.info(`[AudioClip] Deleted clip ${clipId}`) + return true + } + return false + } catch (error) { + Logger.error(`[AudioClip] Failed to delete clip ${clipId}:`, error) + throw error + } + } + + /** + * Get all clips for a specific library item + * @param {string} userId + * @param {string} libraryItemId + * @param {string} [episodeId] + * @returns {Promise} + */ + static async getClipsForItem(userId, libraryItemId, episodeId = null) { + try { + const queryOptions = { + where: { + userId, + libraryItemId + }, + order: [['createdAt', 'DESC']] + } + if (episodeId) { + queryOptions.where.episodeId = episodeId + } + + const clips = await this.findAll(queryOptions) + return clips + } catch (error) { + Logger.error(`[AudioClip] Failed to get clips for item ${libraryItemId}:`, error) + throw error + } + } + + /** + * Get all clips for a user + * @param {string} userId + * @param {Object} [options] + * @param {number} [options.limit] + * @param {number} [options.offset] + * @returns {Promise} + */ + static async getClipsForUser(userId, options = {}) { + try { + const queryOptions = { + where: { userId }, + order: [['createdAt', 'DESC']] + } + + if (options.limit) { + queryOptions.limit = options.limit + } + if (options.offset) { + queryOptions.offset = options.offset + } + + const clips = await this.findAll(queryOptions) + return clips + } catch (error) { + Logger.error(`[AudioClip] Failed to get clips for user ${userId}:`, error) + throw error + } + } + + /** + * Convert to JSON + * @returns {AudioClipObject} + */ + toJSON() { + const clip = this.get({ plain: true }) + return { + id: clip.id, + userId: clip.userId, + libraryItemId: clip.libraryItemId, + episodeId: clip.episodeId, + startTime: clip.startTime, + endTime: clip.endTime, + title: clip.title, + note: clip.note, + createdAt: clip.createdAt?.toISOString(), + updatedAt: clip.updatedAt?.toISOString() + } + } +} + +module.exports = AudioClip diff --git a/test/server/migrations/v2.31.0-audio-clips.test.js b/test/server/migrations/v2.31.0-audio-clips.test.js new file mode 100644 index 000000000..de6a31415 --- /dev/null +++ b/test/server/migrations/v2.31.0-audio-clips.test.js @@ -0,0 +1,284 @@ +const { expect } = require('chai') +const sinon = require('sinon') +const { up, down } = require('../../../server/migrations/v2.31.0-audio-clips') +const { Sequelize } = require('sequelize') +const Logger = require('../../../server/Logger') + +describe('v2.31.0-audio-clips migration', () => { + let sequelize + let queryInterface + let loggerInfoStub + + beforeEach(async () => { + sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }) + queryInterface = sequelize.getQueryInterface() + loggerInfoStub = sinon.stub(Logger, 'info') + + // Create users table with bookmarks + await queryInterface.createTable('users', { + id: { + type: Sequelize.UUID, + defaultValue: Sequelize.UUIDV4, + primaryKey: true + }, + username: Sequelize.STRING, + bookmarks: Sequelize.JSON, + createdAt: Sequelize.DATE, + updatedAt: Sequelize.DATE + }) + + // Create libraryItems table + await queryInterface.createTable('libraryItems', { + id: { + type: Sequelize.UUID, + defaultValue: Sequelize.UUIDV4, + primaryKey: true + }, + title: Sequelize.STRING, + createdAt: Sequelize.DATE, + updatedAt: Sequelize.DATE + }) + }) + + afterEach(() => { + sinon.restore() + }) + + describe('up', () => { + it('should create audioClips table with correct columns', async () => { + await up({ context: { queryInterface, logger: Logger } }) + + const tables = await queryInterface.showAllTables() + expect(tables).to.include('audioClips') + + const tableDescription = await queryInterface.describeTable('audioClips') + expect(tableDescription).to.have.property('id') + expect(tableDescription).to.have.property('userId') + expect(tableDescription).to.have.property('libraryItemId') + expect(tableDescription).to.have.property('episodeId') + expect(tableDescription).to.have.property('startTime') + expect(tableDescription).to.have.property('endTime') + expect(tableDescription).to.have.property('title') + expect(tableDescription).to.have.property('note') + expect(tableDescription).to.have.property('createdAt') + expect(tableDescription).to.have.property('updatedAt') + }) + + it('should create indexes on audioClips table', async () => { + await up({ context: { queryInterface, logger: Logger } }) + + const indexes = await queryInterface.showIndex('audioClips') + const indexNames = indexes.map((idx) => idx.name) + + expect(indexNames).to.include('audio_clips_user_id') + expect(indexNames).to.include('audio_clips_library_item_id') + expect(indexNames).to.include('audio_clips_start_time') + expect(indexNames).to.include('audio_clips_user_library_item') + }) + + it('should migrate bookmarks to clips', async () => { + // Insert library items first (for foreign key constraints) + await queryInterface.bulkInsert('libraryItems', [ + { + id: '11111111-2222-3333-4444-555555555555', + title: 'Test Item', + createdAt: new Date(), + updatedAt: new Date() + } + ]) + + // Insert a user with bookmarks + await queryInterface.bulkInsert('users', [ + { + id: '12345678-1234-1234-1234-123456789012', + username: 'testuser', + bookmarks: JSON.stringify([ + { + libraryItemId: '11111111-2222-3333-4444-555555555555', + time: 100, + title: 'Bookmark 1', + createdAt: new Date('2024-01-01').toISOString() + }, + { + libraryItemId: '11111111-2222-3333-4444-555555555555', + time: 200, + title: 'Bookmark 2', + createdAt: new Date('2024-01-02').toISOString() + } + ]), + createdAt: new Date(), + updatedAt: new Date() + } + ]) + + await up({ context: { queryInterface, logger: Logger } }) + + const [clips] = await queryInterface.sequelize.query('SELECT * FROM audioClips ORDER BY startTime') + + expect(clips).to.have.lengthOf(2) + expect(clips[0].userId).to.equal('12345678-1234-1234-1234-123456789012') + expect(clips[0].libraryItemId).to.equal('11111111-2222-3333-4444-555555555555') + expect(clips[0].startTime).to.equal(100) + expect(clips[0].endTime).to.equal(110) + expect(clips[0].title).to.equal('Bookmark 1') + + expect(clips[1].startTime).to.equal(200) + expect(clips[1].endTime).to.equal(210) + expect(clips[1].title).to.equal('Bookmark 2') + }) + + it('should skip users with no bookmarks', async () => { + await queryInterface.bulkInsert('users', [ + { + id: '12345678-1234-1234-1234-123456789012', + username: 'testuser', + bookmarks: null, + createdAt: new Date(), + updatedAt: new Date() + } + ]) + + await up({ context: { queryInterface, logger: Logger } }) + + const [clips] = await queryInterface.sequelize.query('SELECT * FROM audioClips') + expect(clips).to.have.lengthOf(0) + }) + + it('should skip invalid bookmarks', async () => { + // Insert library items first (for foreign key constraints) + await queryInterface.bulkInsert('libraryItems', [ + { + id: '11111111-2222-3333-4444-555555555555', + title: 'Test Item', + createdAt: new Date(), + updatedAt: new Date() + } + ]) + + await queryInterface.bulkInsert('users', [ + { + id: '12345678-1234-1234-1234-123456789012', + username: 'testuser', + bookmarks: JSON.stringify([ + { + libraryItemId: '11111111-2222-3333-4444-555555555555', + time: 100, + title: 'Valid Bookmark' + }, + { + // Missing libraryItemId + time: 200, + title: 'Invalid Bookmark 1' + }, + { + libraryItemId: '11111111-2222-3333-4444-555555555555', + // Missing time + title: 'Invalid Bookmark 2' + } + ]), + createdAt: new Date(), + updatedAt: new Date() + } + ]) + + await up({ context: { queryInterface, logger: Logger } }) + + const [clips] = await queryInterface.sequelize.query('SELECT * FROM audioClips') + expect(clips).to.have.lengthOf(1) + expect(clips[0].title).to.equal('Valid Bookmark') + }) + + it('should set default title for bookmarks without title', async () => { + // Insert library items first (for foreign key constraints) + await queryInterface.bulkInsert('libraryItems', [ + { + id: '11111111-2222-3333-4444-555555555555', + title: 'Test Item', + createdAt: new Date(), + updatedAt: new Date() + } + ]) + + await queryInterface.bulkInsert('users', [ + { + id: '12345678-1234-1234-1234-123456789012', + username: 'testuser', + bookmarks: JSON.stringify([ + { + libraryItemId: '11111111-2222-3333-4444-555555555555', + time: 100 + } + ]), + createdAt: new Date(), + updatedAt: new Date() + } + ]) + + await up({ context: { queryInterface, logger: Logger } }) + + const [clips] = await queryInterface.sequelize.query('SELECT * FROM audioClips') + expect(clips).to.have.lengthOf(1) + expect(clips[0].title).to.equal('Migrated Bookmark') + }) + + it('should log migration progress', async () => { + // Insert library items first (for foreign key constraints) + await queryInterface.bulkInsert('libraryItems', [ + { + id: '11111111-2222-3333-4444-555555555555', + title: 'Test Item', + createdAt: new Date(), + updatedAt: new Date() + } + ]) + + await queryInterface.bulkInsert('users', [ + { + id: '12345678-1234-1234-1234-123456789012', + username: 'testuser', + bookmarks: JSON.stringify([ + { + libraryItemId: '11111111-2222-3333-4444-555555555555', + time: 100, + title: 'Bookmark 1' + } + ]), + createdAt: new Date(), + updatedAt: new Date() + } + ]) + + await up({ context: { queryInterface, logger: Logger } }) + + expect(loggerInfoStub.calledWith(sinon.match('UPGRADE BEGIN'))).to.be.true + expect(loggerInfoStub.calledWith(sinon.match('Creating audioClips table'))).to.be.true + expect(loggerInfoStub.calledWith(sinon.match('Creating indexes'))).to.be.true + expect(loggerInfoStub.calledWith(sinon.match('Migrating bookmarks'))).to.be.true + expect(loggerInfoStub.calledWith(sinon.match('Migrating 1 bookmarks'))).to.be.true + expect(loggerInfoStub.calledWith(sinon.match('UPGRADE END'))).to.be.true + }) + }) + + describe('down', () => { + it('should drop audioClips table', async () => { + await up({ context: { queryInterface, logger: Logger } }) + await down({ context: { queryInterface, logger: Logger } }) + + const tables = await queryInterface.showAllTables() + expect(tables).not.to.include('audioClips') + }) + + it('should log downgrade progress', async () => { + await up({ context: { queryInterface, logger: Logger } }) + + // Reset stub to clear previous calls + loggerInfoStub.resetHistory() + + await down({ context: { queryInterface, logger: Logger } }) + + expect(loggerInfoStub.calledWith(sinon.match('DOWNGRADE BEGIN'))).to.be.true + expect(loggerInfoStub.calledWith(sinon.match('Dropping audioClips table'))).to.be.true + expect(loggerInfoStub.calledWith(sinon.match('DOWNGRADE END'))).to.be.true + }) + }) +}) diff --git a/test/server/models/AudioClip.test.js b/test/server/models/AudioClip.test.js new file mode 100644 index 000000000..8aac131f7 --- /dev/null +++ b/test/server/models/AudioClip.test.js @@ -0,0 +1,435 @@ +const { expect } = require('chai') +const sinon = require('sinon') +const { Sequelize } = require('sequelize') +const AudioClip = require('../../../server/models/AudioClip') +const Logger = require('../../../server/Logger') + +describe('AudioClip Model', () => { + let sequelize + let loggerStub + + beforeEach(async () => { + sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }) + loggerStub = { + info: sinon.stub(Logger, 'info'), + warn: sinon.stub(Logger, 'warn'), + error: sinon.stub(Logger, 'error') + } + + // Create mock models with proper associations + const User = sequelize.define('user', { + id: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, primaryKey: true }, + username: Sequelize.STRING + }) + + const LibraryItem = sequelize.define('libraryItem', { + id: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, primaryKey: true }, + title: Sequelize.STRING + }) + + AudioClip.init(sequelize) + await sequelize.sync({ force: true }) + + // Create test users + await User.create({ + id: '87654321-4321-4321-4321-210987654321', + username: 'testuser' + }) + await User.create({ + id: 'user1', + username: 'user1' + }) + await User.create({ + id: 'user2', + username: 'user2' + }) + await User.create({ + id: 'user3', + username: 'user3' + }) + + // Create test library items + await LibraryItem.create({ + id: '11111111-2222-3333-4444-555555555555', + title: 'Test Item' + }) + await LibraryItem.create({ + id: 'item1', + title: 'Item 1' + }) + await LibraryItem.create({ + id: 'item2', + title: 'Item 2' + }) + await LibraryItem.create({ + id: 'item3', + title: 'Item 3' + }) + }) + + afterEach(() => { + sinon.restore() + }) + + describe('Model Definition', () => { + it('should create an AudioClip with valid data', async () => { + const clip = await AudioClip.create({ + id: '12345678-1234-1234-1234-123456789012', + userId: '87654321-4321-4321-4321-210987654321', + libraryItemId: '11111111-2222-3333-4444-555555555555', + startTime: 10.5, + endTime: 20.8, + title: 'Test Clip', + note: 'Test note' + }) + + expect(clip).to.exist + expect(clip.id).to.equal('12345678-1234-1234-1234-123456789012') + expect(clip.userId).to.equal('87654321-4321-4321-4321-210987654321') + expect(clip.startTime).to.equal(10.5) + expect(clip.endTime).to.equal(20.8) + expect(clip.title).to.equal('Test Clip') + expect(clip.note).to.equal('Test note') + }) + + it('should require userId', async () => { + try { + await AudioClip.create({ + libraryItemId: '11111111-2222-3333-4444-555555555555', + startTime: 10, + endTime: 20, + title: 'Test' + }) + expect.fail('Should have thrown validation error') + } catch (error) { + expect(error.name).to.equal('SequelizeValidationError') + } + }) + + it('should require libraryItemId', async () => { + try { + await AudioClip.create({ + userId: '87654321-4321-4321-4321-210987654321', + startTime: 10, + endTime: 20, + title: 'Test' + }) + expect.fail('Should have thrown validation error') + } catch (error) { + expect(error.name).to.equal('SequelizeValidationError') + } + }) + + it('should allow null episodeId', async () => { + const clip = await AudioClip.create({ + userId: '87654321-4321-4321-4321-210987654321', + libraryItemId: '11111111-2222-3333-4444-555555555555', + episodeId: null, + startTime: 10, + endTime: 20, + title: 'Test' + }) + + expect(clip.episodeId).to.be.null + }) + }) + + describe('getDuration', () => { + it('should calculate duration correctly', async () => { + const clip = await AudioClip.create({ + userId: '87654321-4321-4321-4321-210987654321', + libraryItemId: '11111111-2222-3333-4444-555555555555', + startTime: 10.5, + endTime: 25.8, + title: 'Test' + }) + + expect(clip.getDuration()).to.be.closeTo(15.3, 0.01) + }) + }) + + describe('isValidTimeRange', () => { + it('should return true for valid time range', async () => { + const clip = await AudioClip.create({ + userId: '87654321-4321-4321-4321-210987654321', + libraryItemId: '11111111-2222-3333-4444-555555555555', + startTime: 10, + endTime: 20, + title: 'Test' + }) + + expect(clip.isValidTimeRange()).to.be.true + }) + + it('should return false for negative start time', async () => { + const clip = AudioClip.build({ + userId: '87654321-4321-4321-4321-210987654321', + libraryItemId: '11111111-2222-3333-4444-555555555555', + startTime: -5, + endTime: 20, + title: 'Test' + }) + + expect(clip.isValidTimeRange()).to.be.false + }) + + it('should return false when endTime <= startTime', async () => { + const clip = AudioClip.build({ + userId: '87654321-4321-4321-4321-210987654321', + libraryItemId: '11111111-2222-3333-4444-555555555555', + startTime: 20, + endTime: 20, + title: 'Test' + }) + + expect(clip.isValidTimeRange()).to.be.false + }) + }) + + describe('createClip', () => { + it('should create a clip with valid data', async () => { + const clip = await AudioClip.createClip('87654321-4321-4321-4321-210987654321', '11111111-2222-3333-4444-555555555555', 10, 20, 'Test Clip', 'Test note') + + expect(clip).to.exist + expect(clip.startTime).to.equal(10) + expect(clip.endTime).to.equal(20) + expect(clip.title).to.equal('Test Clip') + expect(clip.note).to.equal('Test note') + expect(loggerStub.info.calledWith(sinon.match(/Created clip/))).to.be.true + }) + + it('should throw error for negative start time', async () => { + try { + await AudioClip.createClip('87654321-4321-4321-4321-210987654321', '11111111-2222-3333-4444-555555555555', -5, 20, 'Test') + expect.fail('Should have thrown error') + } catch (error) { + expect(error.message).to.include('Start time must be non-negative') + } + }) + + it('should throw error when endTime <= startTime', async () => { + try { + await AudioClip.createClip('87654321-4321-4321-4321-210987654321', '11111111-2222-3333-4444-555555555555', 20, 15, 'Test') + expect.fail('Should have thrown error') + } catch (error) { + expect(error.message).to.include('End time must be greater than start time') + } + }) + + it('should warn for clips longer than 10 minutes', async () => { + await AudioClip.createClip('87654321-4321-4321-4321-210987654321', '11111111-2222-3333-4444-555555555555', 0, 700, 'Long Clip') + + expect(loggerStub.warn.calledWith(sinon.match(/long duration/))).to.be.true + }) + }) + + describe('updateClip', () => { + it('should update an existing clip', async () => { + const clip = await AudioClip.createClip('87654321-4321-4321-4321-210987654321', '11111111-2222-3333-4444-555555555555', 10, 20, 'Original Title') + + const updated = await AudioClip.updateClip(clip.id, { + title: 'Updated Title', + note: 'Updated note' + }) + + expect(updated.title).to.equal('Updated Title') + expect(updated.note).to.equal('Updated note') + expect(updated.startTime).to.equal(10) + expect(updated.endTime).to.equal(20) + }) + + it('should update time range', async () => { + const clip = await AudioClip.createClip('87654321-4321-4321-4321-210987654321', '11111111-2222-3333-4444-555555555555', 10, 20, 'Test') + + const updated = await AudioClip.updateClip(clip.id, { + startTime: 15, + endTime: 25 + }) + + expect(updated.startTime).to.equal(15) + expect(updated.endTime).to.equal(25) + }) + + it('should throw error for invalid clip id', async () => { + try { + await AudioClip.updateClip('nonexistent-id', { title: 'Test' }) + expect.fail('Should have thrown error') + } catch (error) { + expect(error.message).to.include('Clip not found') + } + }) + + it('should validate time range on update', async () => { + const clip = await AudioClip.createClip('87654321-4321-4321-4321-210987654321', '11111111-2222-3333-4444-555555555555', 10, 20, 'Test') + + try { + await AudioClip.updateClip(clip.id, { + endTime: 5 + }) + expect.fail('Should have thrown error') + } catch (error) { + expect(error.message).to.include('End time must be greater than start time') + } + }) + }) + + describe('deleteClip', () => { + it('should delete a clip', async () => { + const clip = await AudioClip.createClip('87654321-4321-4321-4321-210987654321', '11111111-2222-3333-4444-555555555555', 10, 20, 'Test') + + const deleted = await AudioClip.deleteClip(clip.id) + expect(deleted).to.be.true + + const found = await AudioClip.findByPk(clip.id) + expect(found).to.be.null + }) + + it('should return false for nonexistent clip', async () => { + const deleted = await AudioClip.deleteClip('nonexistent-id') + expect(deleted).to.be.false + }) + }) + + describe('getClipsForItem', () => { + it('should return clips for a library item', async () => { + await AudioClip.createClip('user1', 'item1', 10, 20, 'Clip 1') + await AudioClip.createClip('user1', 'item1', 30, 40, 'Clip 2') + await AudioClip.createClip('user1', 'item2', 50, 60, 'Clip 3') + + const clips = await AudioClip.getClipsForItem('user1', 'item1') + expect(clips).to.have.lengthOf(2) + const startTimes = clips.map(c => c.startTime).sort((a, b) => a - b) + expect(startTimes).to.deep.equal([10, 30]) + }) + + it('should filter by episodeId', async () => { + await AudioClip.createClip('user1', 'item1', 10, 20, 'Clip 1', null, 'episode1') + await AudioClip.createClip('user1', 'item1', 30, 40, 'Clip 2', null, 'episode2') + + const clips = await AudioClip.getClipsForItem('user1', 'item1', 'episode1') + expect(clips).to.have.lengthOf(1) + expect(clips[0].episodeId).to.equal('episode1') + }) + + it('should return all clips for item', async () => { + await AudioClip.createClip('user1', 'item1', 30, 40, 'Clip 2') + await AudioClip.createClip('user1', 'item1', 10, 20, 'Clip 1') + await AudioClip.createClip('user1', 'item1', 50, 60, 'Clip 3') + + const clips = await AudioClip.getClipsForItem('user1', 'item1') + expect(clips).to.have.lengthOf(3) + const startTimes = clips.map(c => c.startTime).sort((a, b) => a - b) + expect(startTimes).to.deep.equal([10, 30, 50]) + }) + + it('should only return clips for the specified user', async () => { + await AudioClip.createClip('user1', 'item1', 10, 20, 'User 1 Clip') + await AudioClip.createClip('user2', 'item1', 30, 40, 'User 2 Clip') + await AudioClip.createClip('user1', 'item1', 50, 60, 'User 1 Clip 2') + + const user1Clips = await AudioClip.getClipsForItem('user1', 'item1') + expect(user1Clips).to.have.lengthOf(2) + expect(user1Clips.every((c) => c.userId === 'user1')).to.be.true + + const user2Clips = await AudioClip.getClipsForItem('user2', 'item1') + expect(user2Clips).to.have.lengthOf(1) + expect(user2Clips[0].userId).to.equal('user2') + }) + + it('should not return other users clips for the same item', async () => { + // Create clips from multiple users on the same item + await AudioClip.createClip('user1', 'item1', 5, 15, 'User 1 First Clip') + await AudioClip.createClip('user2', 'item1', 10, 20, 'User 2 First Clip') + await AudioClip.createClip('user3', 'item1', 15, 25, 'User 3 First Clip') + await AudioClip.createClip('user1', 'item1', 20, 30, 'User 1 Second Clip') + await AudioClip.createClip('user2', 'item1', 25, 35, 'User 2 Second Clip') + + // Each user should only see their own clips + const user1Clips = await AudioClip.getClipsForItem('user1', 'item1') + expect(user1Clips).to.have.lengthOf(2) + expect(user1Clips.every((c) => c.userId === 'user1')).to.be.true + expect(user1Clips.some((c) => c.title.includes('User 2'))).to.be.false + expect(user1Clips.some((c) => c.title.includes('User 3'))).to.be.false + + const user2Clips = await AudioClip.getClipsForItem('user2', 'item1') + expect(user2Clips).to.have.lengthOf(2) + expect(user2Clips.every((c) => c.userId === 'user2')).to.be.true + expect(user2Clips.some((c) => c.title.includes('User 1'))).to.be.false + expect(user2Clips.some((c) => c.title.includes('User 3'))).to.be.false + + const user3Clips = await AudioClip.getClipsForItem('user3', 'item1') + expect(user3Clips).to.have.lengthOf(1) + expect(user3Clips[0].userId).to.equal('user3') + expect(user3Clips.some((c) => c.title.includes('User 1'))).to.be.false + expect(user3Clips.some((c) => c.title.includes('User 2'))).to.be.false + }) + + it('should return empty array when user has no clips for item', async () => { + await AudioClip.createClip('user1', 'item1', 10, 20, 'User 1 Clip') + await AudioClip.createClip('user2', 'item1', 30, 40, 'User 2 Clip') + + // user3 has no clips for item1 + const user3Clips = await AudioClip.getClipsForItem('user3', 'item1') + expect(user3Clips).to.have.lengthOf(0) + }) + + it('should filter by both user and episode', async () => { + await AudioClip.createClip('user1', 'item1', 10, 20, 'User 1 Episode 1', null, 'episode1') + await AudioClip.createClip('user1', 'item1', 30, 40, 'User 1 Episode 2', null, 'episode2') + await AudioClip.createClip('user2', 'item1', 50, 60, 'User 2 Episode 1', null, 'episode1') + + const user1Episode1Clips = await AudioClip.getClipsForItem('user1', 'item1', 'episode1') + expect(user1Episode1Clips).to.have.lengthOf(1) + expect(user1Episode1Clips[0].userId).to.equal('user1') + expect(user1Episode1Clips[0].episodeId).to.equal('episode1') + expect(user1Episode1Clips[0].title).to.equal('User 1 Episode 1') + }) + }) + + describe('getClipsForUser', () => { + it('should return clips for a user', async () => { + await AudioClip.createClip('user1', 'item1', 10, 20, 'Clip 1') + await AudioClip.createClip('user1', 'item2', 30, 40, 'Clip 2') + await AudioClip.createClip('user2', 'item1', 50, 60, 'Clip 3') + + const clips = await AudioClip.getClipsForUser('user1') + expect(clips).to.have.lengthOf(2) + }) + + it('should sort by creation date descending', async () => { + const clip1 = await AudioClip.createClip('user1', 'item1', 10, 20, 'Clip 1') + await new Promise((resolve) => setTimeout(resolve, 10)) + const clip2 = await AudioClip.createClip('user1', 'item2', 30, 40, 'Clip 2') + + const clips = await AudioClip.getClipsForUser('user1') + expect(clips[0].id).to.equal(clip2.id) + expect(clips[1].id).to.equal(clip1.id) + }) + + it('should support limit and offset', async () => { + await AudioClip.createClip('user1', 'item1', 10, 20, 'Clip 1') + await AudioClip.createClip('user1', 'item2', 30, 40, 'Clip 2') + await AudioClip.createClip('user1', 'item3', 50, 60, 'Clip 3') + + const clips = await AudioClip.getClipsForUser('user1', { limit: 2, offset: 1 }) + expect(clips).to.have.lengthOf(2) + }) + }) + + describe('toJSON', () => { + it('should serialize clip to JSON', async () => { + const clip = await AudioClip.createClip('user1', 'item1', 10.5, 20.8, 'Test Clip', 'Test note') + + const json = clip.toJSON() + expect(json).to.have.property('id') + expect(json).to.have.property('userId', 'user1') + expect(json).to.have.property('libraryItemId', 'item1') + expect(json).to.have.property('startTime', 10.5) + expect(json).to.have.property('endTime', 20.8) + expect(json).to.have.property('title', 'Test Clip') + expect(json).to.have.property('note', 'Test note') + expect(json).to.have.property('createdAt') + expect(json).to.have.property('updatedAt') + }) + }) +}) From cdb0bbb4d2feeae6a7cea8943c04a3909523b219 Mon Sep 17 00:00:00 2001 From: Jozsef Kiraly <371847+fonix232@users.noreply.github.com> Date: Thu, 27 Nov 2025 11:55:52 +0000 Subject: [PATCH 2/2] feat: Add AudioClip to database proper Also: API endpoints and controller implementations --- server/Database.js | 6 + server/controllers/MeController.js | 166 ++++ server/routers/ApiRouter.js | 5 + .../controllers/MeController.clips.test.js | 806 ++++++++++++++++++ 4 files changed, 983 insertions(+) create mode 100644 test/server/controllers/MeController.clips.test.js diff --git a/server/Database.js b/server/Database.js index 213c2c61b..b4c002f08 100644 --- a/server/Database.js +++ b/server/Database.js @@ -162,6 +162,11 @@ class Database { return this.models.device } + /** @type {typeof import('./models/AudioClip')} */ + get audioClipModel() { + return this.models.audioClip + } + /** * Check if db file exists * @returns {boolean} @@ -345,6 +350,7 @@ class Database { require('./models/Setting').init(this.sequelize) require('./models/CustomMetadataProvider').init(this.sequelize) require('./models/MediaItemShare').init(this.sequelize) + require('./models/AudioClip').init(this.sequelize) return this.sequelize.sync({ force, alter: false }) } diff --git a/server/controllers/MeController.js b/server/controllers/MeController.js index 51773a5ad..33c82b658 100644 --- a/server/controllers/MeController.js +++ b/server/controllers/MeController.js @@ -473,5 +473,171 @@ class MeController { const data = await userStats.getStatsForYear(req.user.id, year) res.json(data) } + + /** + * GET: /api/me/clips + * Get all clips for the authenticated user + * + * @param {RequestWithUser} req + * @param {Response} res + */ + async getClips(req, res) { + try { + const clips = await Database.audioClipModel.getClipsForUser(req.user.id) + res.json({ clips: clips.map((c) => c.toJSON()) }) + } catch (error) { + Logger.error(`[MeController] Failed to get clips:`, error) + res.status(500).send('Failed to get clips') + } + } + + /** + * GET: /api/me/items/:id/clips + * Get all clips for a specific library item + * + * @param {RequestWithUser} req + * @param {Response} res + */ + async getItemClips(req, res) { + if (!(await Database.libraryItemModel.checkExistsById(req.params.id))) return res.sendStatus(404) + + try { + const episodeId = req.query.episodeId || null + const clips = await Database.audioClipModel.getClipsForItem(req.user.id, req.params.id, episodeId) + res.json({ clips: clips.map((c) => c.toJSON()) }) + } catch (error) { + Logger.error(`[MeController] Failed to get clips for item:`, error) + res.status(500).send('Failed to get clips') + } + } + + /** + * POST: /api/me/items/:id/clips + * Create a new clip for a library item + * + * @param {RequestWithUser} req + * @param {Response} res + */ + async createClip(req, res) { + if (!(await Database.libraryItemModel.checkExistsById(req.params.id))) return res.sendStatus(404) + + const { startTime, endTime, title, note, episodeId } = req.body + + // Validate required fields + if (isNullOrNaN(startTime)) { + Logger.error(`[MeController] createClip invalid startTime`, startTime) + return res.status(400).send('Invalid start time') + } + if (isNullOrNaN(endTime)) { + Logger.error(`[MeController] createClip invalid endTime`, endTime) + return res.status(400).send('Invalid end time') + } + if (!title || typeof title !== 'string') { + Logger.error(`[MeController] createClip invalid title`, title) + return res.status(400).send('Invalid title') + } + + try { + const clip = await Database.audioClipModel.createClip(req.user.id, req.params.id, startTime, endTime, title, note, episodeId) + + SocketAuthority.clientEmitter(req.user.id, 'clip_created', clip.toJSON()) + res.json(clip.toJSON()) + } catch (error) { + Logger.error(`[MeController] Failed to create clip:`, error) + res.status(400).send(error.message || 'Failed to create clip') + } + } + + /** + * PATCH: /api/me/clips/:clipId + * Update an existing clip + * + * @param {RequestWithUser} req + * @param {Response} res + */ + async updateClip(req, res) { + const clipId = req.params.clipId + + // Check if clip exists and belongs to user + const existingClip = await Database.audioClipModel.findByPk(clipId) + if (!existingClip) { + Logger.error(`[MeController] updateClip not found for clip id "${clipId}"`) + return res.sendStatus(404) + } + if (existingClip.userId !== req.user.id) { + Logger.error(`[MeController] updateClip forbidden - clip does not belong to user`) + return res.sendStatus(403) + } + + const { startTime, endTime, title, note } = req.body + const updates = {} + + if (startTime !== undefined) { + if (isNullOrNaN(startTime)) { + Logger.error(`[MeController] updateClip invalid startTime`, startTime) + return res.status(400).send('Invalid start time') + } + updates.startTime = startTime + } + if (endTime !== undefined) { + if (isNullOrNaN(endTime)) { + Logger.error(`[MeController] updateClip invalid endTime`, endTime) + return res.status(400).send('Invalid end time') + } + updates.endTime = endTime + } + if (title !== undefined) { + if (typeof title !== 'string') { + Logger.error(`[MeController] updateClip invalid title`, title) + return res.status(400).send('Invalid title') + } + updates.title = title + } + if (note !== undefined) { + updates.note = note + } + + try { + const clip = await Database.audioClipModel.updateClip(clipId, updates) + + SocketAuthority.clientEmitter(req.user.id, 'clip_updated', clip.toJSON()) + res.json(clip.toJSON()) + } catch (error) { + Logger.error(`[MeController] Failed to update clip:`, error) + res.status(400).send(error.message || 'Failed to update clip') + } + } + + /** + * DELETE: /api/me/clips/:clipId + * Delete a clip + * + * @param {RequestWithUser} req + * @param {Response} res + */ + async deleteClip(req, res) { + const clipId = req.params.clipId + + // Check if clip exists and belongs to user + const existingClip = await Database.audioClipModel.findByPk(clipId) + if (!existingClip) { + Logger.error(`[MeController] deleteClip not found for clip id "${clipId}"`) + return res.sendStatus(404) + } + if (existingClip.userId !== req.user.id) { + Logger.error(`[MeController] deleteClip forbidden - clip does not belong to user`) + return res.sendStatus(403) + } + + try { + await Database.audioClipModel.deleteClip(clipId) + + SocketAuthority.clientEmitter(req.user.id, 'clip_removed', { id: clipId }) + res.sendStatus(200) + } catch (error) { + Logger.error(`[MeController] Failed to delete clip:`, error) + res.status(500).send('Failed to delete clip') + } + } } module.exports = new MeController() diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index db04bf5ec..ed2ff9310 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -182,6 +182,11 @@ class ApiRouter { this.router.post('/me/item/:id/bookmark', MeController.createBookmark.bind(this)) this.router.patch('/me/item/:id/bookmark', MeController.updateBookmark.bind(this)) this.router.delete('/me/item/:id/bookmark/:time', MeController.removeBookmark.bind(this)) + this.router.get('/me/clips', MeController.getClips.bind(this)) + this.router.get('/me/item/:id/clips', MeController.getItemClips.bind(this)) + this.router.post('/me/item/:id/clip', MeController.createClip.bind(this)) + this.router.patch('/me/clip/:clipId', MeController.updateClip.bind(this)) + this.router.delete('/me/clip/:clipId', MeController.deleteClip.bind(this)) this.router.patch('/me/password', this.auth.authRateLimiter, MeController.updatePassword.bind(this)) this.router.get('/me/items-in-progress', MeController.getAllLibraryItemsInProgress.bind(this)) this.router.get('/me/series/:id/remove-from-continue-listening', MeController.removeSeriesFromContinueListening.bind(this)) diff --git a/test/server/controllers/MeController.clips.test.js b/test/server/controllers/MeController.clips.test.js new file mode 100644 index 000000000..746ccbf23 --- /dev/null +++ b/test/server/controllers/MeController.clips.test.js @@ -0,0 +1,806 @@ +const { expect } = require('chai') +const sinon = require('sinon') +const { Sequelize } = require('sequelize') +const Database = require('../../../server/Database') +const MeController = require('../../../server/controllers/MeController') +const Logger = require('../../../server/Logger') +const SocketAuthority = require('../../../server/SocketAuthority') + +describe('MeController Clips Endpoints', () => { + let loggerStub + let socketEmitStub + + beforeEach(async () => { + Database.sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }) + await Database.buildModels() + + loggerStub = { + info: sinon.stub(Logger, 'info'), + error: sinon.stub(Logger, 'error'), + warn: sinon.stub(Logger, 'warn') + } + socketEmitStub = sinon.stub(SocketAuthority, 'clientEmitter') + }) + + afterEach(() => { + sinon.restore() + }) + + describe('getClips', () => { + it('should return all clips for authenticated user', async () => { + const user = await Database.userModel.create({ + username: 'testuser', + pash: 'test', + type: 'user', + isActive: true + }) + + const libraryItem = await createTestLibraryItem() + + await Database.audioClipModel.createClip(user.id, libraryItem.id, 10, 20, 'Clip 1') + await Database.audioClipModel.createClip(user.id, libraryItem.id, 30, 40, 'Clip 2') + + const req = { user } + const res = { + json: sinon.spy(), + status: sinon.stub().returnsThis(), + send: sinon.spy() + } + + await MeController.getClips(req, res) + + expect(res.json.calledOnce).to.be.true + const response = res.json.firstCall.args[0] + expect(response.clips).to.have.lengthOf(2) + expect(response.clips[0]).to.have.property('title') + expect(response.clips[0]).to.have.property('startTime') + expect(response.clips[0]).to.have.property('endTime') + }) + + it('should handle errors gracefully', async () => { + const user = await Database.userModel.create({ + username: 'testuser', + pash: 'test', + type: 'user', + isActive: true + }) + + // Simulate database error + sinon.stub(Database.audioClipModel, 'getClipsForUser').rejects(new Error('Database error')) + + const req = { user } + const res = { + json: sinon.spy(), + status: sinon.stub().returnsThis(), + send: sinon.spy() + } + + await MeController.getClips(req, res) + + expect(res.status.calledWith(500)).to.be.true + expect(res.send.calledWith('Failed to get clips')).to.be.true + }) + }) + + describe('getItemClips', () => { + it('should return clips for a specific library item', async () => { + const user = await Database.userModel.create({ + username: 'testuser', + pash: 'test', + type: 'user', + isActive: true + }) + + const libraryItem = await createTestLibraryItem() + + await Database.audioClipModel.createClip(user.id, libraryItem.id, 10, 20, 'Clip 1') + await Database.audioClipModel.createClip(user.id, libraryItem.id, 30, 40, 'Clip 2') + + const req = { + user, + params: { id: libraryItem.id }, + query: {} + } + const res = { + json: sinon.spy(), + sendStatus: sinon.spy(), + status: sinon.stub().returnsThis(), + send: sinon.spy() + } + + await MeController.getItemClips(req, res) + + expect(res.json.calledOnce).to.be.true + const response = res.json.firstCall.args[0] + expect(response.clips).to.have.lengthOf(2) + }) + + it('should return 404 for non-existent library item', async () => { + const user = await Database.userModel.create({ + username: 'testuser', + pash: 'test', + type: 'user', + isActive: true + }) + + const req = { + user, + params: { id: 'nonexistent-id' }, + query: {} + } + const res = { + sendStatus: sinon.spy() + } + + await MeController.getItemClips(req, res) + + expect(res.sendStatus.calledWith(404)).to.be.true + }) + + it('should filter by episodeId when provided', async () => { + const user = await Database.userModel.create({ + username: 'testuser', + pash: 'test', + type: 'user', + isActive: true + }) + + const libraryItem = await createTestLibraryItem() + + await Database.audioClipModel.createClip(user.id, libraryItem.id, 10, 20, 'Clip 1', null, 'episode1') + await Database.audioClipModel.createClip(user.id, libraryItem.id, 30, 40, 'Clip 2', null, 'episode2') + + const req = { + user, + params: { id: libraryItem.id }, + query: { episodeId: 'episode1' } + } + const res = { + json: sinon.spy(), + status: sinon.stub().returnsThis(), + send: sinon.spy() + } + + await MeController.getItemClips(req, res) + + const response = res.json.firstCall.args[0] + expect(response.clips).to.have.lengthOf(1) + expect(response.clips[0].episodeId).to.equal('episode1') + }) + + it('should only return clips for the authenticated user', async () => { + const user1 = await Database.userModel.create({ + username: 'user1', + pash: 'test', + type: 'user', + isActive: true + }) + + const user2 = await Database.userModel.create({ + username: 'user2', + pash: 'test', + type: 'user', + isActive: true + }) + + const libraryItem = await createTestLibraryItem() + + // User 1 creates clips + await Database.audioClipModel.createClip(user1.id, libraryItem.id, 10, 20, 'User 1 Clip 1') + await Database.audioClipModel.createClip(user1.id, libraryItem.id, 30, 40, 'User 1 Clip 2') + + // User 2 creates clips + await Database.audioClipModel.createClip(user2.id, libraryItem.id, 50, 60, 'User 2 Clip 1') + + // User 1 requests clips - should only get their own + const req1 = { + user: user1, + params: { id: libraryItem.id }, + query: {} + } + const res1 = { + json: sinon.spy(), + status: sinon.stub().returnsThis(), + send: sinon.spy() + } + + await MeController.getItemClips(req1, res1) + + const response1 = res1.json.firstCall.args[0] + expect(response1.clips).to.have.lengthOf(2) + expect(response1.clips.every(c => c.userId === user1.id)).to.be.true + expect(response1.clips.some(c => c.title.includes('User 2'))).to.be.false + + // User 2 requests clips - should only get their own + const req2 = { + user: user2, + params: { id: libraryItem.id }, + query: {} + } + const res2 = { + json: sinon.spy(), + status: sinon.stub().returnsThis(), + send: sinon.spy() + } + + await MeController.getItemClips(req2, res2) + + const response2 = res2.json.firstCall.args[0] + expect(response2.clips).to.have.lengthOf(1) + expect(response2.clips[0].userId).to.equal(user2.id) + expect(response2.clips.some(c => c.title.includes('User 1'))).to.be.false + }) + + it('should handle errors gracefully', async () => { + const user = await Database.userModel.create({ + username: 'testuser', + pash: 'test', + type: 'user', + isActive: true + }) + + const libraryItem = await createTestLibraryItem() + + // Simulate database error + sinon.stub(Database.audioClipModel, 'getClipsForItem').rejects(new Error('Database error')) + + const req = { + user, + params: { id: libraryItem.id }, + query: {} + } + const res = { + json: sinon.spy(), + status: sinon.stub().returnsThis(), + send: sinon.spy() + } + + await MeController.getItemClips(req, res) + + expect(res.status.calledWith(500)).to.be.true + expect(res.send.calledWith('Failed to get clips')).to.be.true + }) + }) + + describe('createClip', () => { + it('should create a new clip', async () => { + const user = await Database.userModel.create({ + username: 'testuser', + pash: 'test', + type: 'user', + isActive: true + }) + + const libraryItem = await createTestLibraryItem() + + const req = { + user, + params: { id: libraryItem.id }, + body: { + startTime: 10, + endTime: 20, + title: 'Test Clip', + note: 'Test note' + } + } + const res = { + json: sinon.spy(), + status: sinon.stub().returnsThis(), + send: sinon.spy() + } + + await MeController.createClip(req, res) + + expect(res.json.calledOnce).to.be.true + const clip = res.json.firstCall.args[0] + expect(clip.title).to.equal('Test Clip') + expect(clip.startTime).to.equal(10) + expect(clip.endTime).to.equal(20) + expect(clip.note).to.equal('Test note') + expect(socketEmitStub.calledWith(user.id, 'clip_created')).to.be.true + }) + + it('should return 404 for non-existent library item', async () => { + const user = await Database.userModel.create({ + username: 'testuser', + pash: 'test', + type: 'user', + isActive: true + }) + + const req = { + user, + params: { id: 'nonexistent-id' }, + body: { + startTime: 10, + endTime: 20, + title: 'Test' + } + } + const res = { + sendStatus: sinon.spy() + } + + await MeController.createClip(req, res) + + expect(res.sendStatus.calledWith(404)).to.be.true + }) + + it('should validate startTime', async () => { + const user = await Database.userModel.create({ + username: 'testuser', + pash: 'test', + type: 'user', + isActive: true + }) + + const libraryItem = await createTestLibraryItem() + + const req = { + user, + params: { id: libraryItem.id }, + body: { + startTime: 'invalid', + endTime: 20, + title: 'Test' + } + } + const res = { + status: sinon.stub().returnsThis(), + send: sinon.spy() + } + + await MeController.createClip(req, res) + + expect(res.status.calledWith(400)).to.be.true + expect(res.send.calledWith('Invalid start time')).to.be.true + }) + + it('should validate endTime', async () => { + const user = await Database.userModel.create({ + username: 'testuser', + pash: 'test', + type: 'user', + isActive: true + }) + + const libraryItem = await createTestLibraryItem() + + const req = { + user, + params: { id: libraryItem.id }, + body: { + startTime: 10, + endTime: null, + title: 'Test' + } + } + const res = { + status: sinon.stub().returnsThis(), + send: sinon.spy() + } + + await MeController.createClip(req, res) + + expect(res.status.calledWith(400)).to.be.true + expect(res.send.calledWith('Invalid end time')).to.be.true + }) + + it('should validate title', async () => { + const user = await Database.userModel.create({ + username: 'testuser', + pash: 'test', + type: 'user', + isActive: true + }) + + const libraryItem = await createTestLibraryItem() + + const req = { + user, + params: { id: libraryItem.id }, + body: { + startTime: 10, + endTime: 20, + title: null + } + } + const res = { + status: sinon.stub().returnsThis(), + send: sinon.spy() + } + + await MeController.createClip(req, res) + + expect(res.status.calledWith(400)).to.be.true + expect(res.send.calledWith('Invalid title')).to.be.true + }) + + it('should handle errors gracefully', async () => { + const user = await Database.userModel.create({ + username: 'testuser', + pash: 'test', + type: 'user', + isActive: true + }) + + const libraryItem = await createTestLibraryItem() + + // Simulate database error with custom message + sinon.stub(Database.audioClipModel, 'createClip').rejects(new Error('Time range invalid')) + + const req = { + user, + params: { id: libraryItem.id }, + body: { + startTime: 10, + endTime: 20, + title: 'Test' + } + } + const res = { + json: sinon.spy(), + status: sinon.stub().returnsThis(), + send: sinon.spy() + } + + await MeController.createClip(req, res) + + expect(res.status.calledWith(400)).to.be.true + expect(res.send.calledWith('Time range invalid')).to.be.true + }) + }) + + describe('updateClip', () => { + it('should update an existing clip', async () => { + const user = await Database.userModel.create({ + username: 'testuser', + pash: 'test', + type: 'user', + isActive: true + }) + + const libraryItem = await createTestLibraryItem() + const clip = await Database.audioClipModel.createClip(user.id, libraryItem.id, 10, 20, 'Original Title') + + const req = { + user, + params: { clipId: clip.id }, + body: { + title: 'Updated Title', + note: 'Updated note' + } + } + const res = { + json: sinon.spy(), + status: sinon.stub().returnsThis(), + send: sinon.spy() + } + + await MeController.updateClip(req, res) + + expect(res.json.calledOnce).to.be.true + const updatedClip = res.json.firstCall.args[0] + expect(updatedClip.title).to.equal('Updated Title') + expect(updatedClip.note).to.equal('Updated note') + expect(socketEmitStub.calledWith(user.id, 'clip_updated')).to.be.true + }) + + it('should return 404 for non-existent clip', async () => { + const user = await Database.userModel.create({ + username: 'testuser', + pash: 'test', + type: 'user', + isActive: true + }) + + const req = { + user, + params: { clipId: 'nonexistent-id' }, + body: { title: 'Test' } + } + const res = { + sendStatus: sinon.spy() + } + + await MeController.updateClip(req, res) + + expect(res.sendStatus.calledWith(404)).to.be.true + }) + + it('should validate startTime when updating', async () => { + const user = await Database.userModel.create({ + username: 'testuser', + pash: 'test', + type: 'user', + isActive: true + }) + + const libraryItem = await createTestLibraryItem() + const clip = await Database.audioClipModel.createClip(user.id, libraryItem.id, 10, 20, 'Test') + + const req = { + user, + params: { clipId: clip.id }, + body: { + startTime: 'invalid' + } + } + const res = { + status: sinon.stub().returnsThis(), + send: sinon.spy() + } + + await MeController.updateClip(req, res) + + expect(res.status.calledWith(400)).to.be.true + expect(res.send.calledWith('Invalid start time')).to.be.true + }) + + it('should validate endTime when updating', async () => { + const user = await Database.userModel.create({ + username: 'testuser', + pash: 'test', + type: 'user', + isActive: true + }) + + const libraryItem = await createTestLibraryItem() + const clip = await Database.audioClipModel.createClip(user.id, libraryItem.id, 10, 20, 'Test') + + const req = { + user, + params: { clipId: clip.id }, + body: { + endTime: null + } + } + const res = { + status: sinon.stub().returnsThis(), + send: sinon.spy() + } + + await MeController.updateClip(req, res) + + expect(res.status.calledWith(400)).to.be.true + expect(res.send.calledWith('Invalid end time')).to.be.true + }) + + it('should validate title when updating', async () => { + const user = await Database.userModel.create({ + username: 'testuser', + pash: 'test', + type: 'user', + isActive: true + }) + + const libraryItem = await createTestLibraryItem() + const clip = await Database.audioClipModel.createClip(user.id, libraryItem.id, 10, 20, 'Test') + + const req = { + user, + params: { clipId: clip.id }, + body: { + title: 123 + } + } + const res = { + status: sinon.stub().returnsThis(), + send: sinon.spy() + } + + await MeController.updateClip(req, res) + + expect(res.status.calledWith(400)).to.be.true + expect(res.send.calledWith('Invalid title')).to.be.true + }) + + it('should return 403 if clip belongs to another user', async () => { + const user1 = await Database.userModel.create({ + username: 'user1', + pash: 'test', + type: 'user', + isActive: true + }) + + const user2 = await Database.userModel.create({ + username: 'user2', + pash: 'test', + type: 'user', + isActive: true + }) + + const libraryItem = await createTestLibraryItem() + const clip = await Database.audioClipModel.createClip(user1.id, libraryItem.id, 10, 20, 'Test') + + const req = { + user: user2, + params: { clipId: clip.id }, + body: { title: 'Updated' } + } + const res = { + sendStatus: sinon.spy() + } + + await MeController.updateClip(req, res) + + expect(res.sendStatus.calledWith(403)).to.be.true + }) + + it('should handle errors gracefully', async () => { + const user = await Database.userModel.create({ + username: 'testuser', + pash: 'test', + type: 'user', + isActive: true + }) + + const libraryItem = await createTestLibraryItem() + const clip = await Database.audioClipModel.createClip(user.id, libraryItem.id, 10, 20, 'Test') + + // Simulate database error + sinon.stub(Database.audioClipModel, 'updateClip').rejects(new Error('Database error')) + + const req = { + user, + params: { clipId: clip.id }, + body: { title: 'Updated' } + } + const res = { + json: sinon.spy(), + status: sinon.stub().returnsThis(), + send: sinon.spy() + } + + await MeController.updateClip(req, res) + + expect(res.status.calledWith(400)).to.be.true + expect(res.send.called).to.be.true + }) + }) + + describe('deleteClip', () => { + it('should delete a clip', async () => { + const user = await Database.userModel.create({ + username: 'testuser', + pash: 'test', + type: 'user', + isActive: true + }) + + const libraryItem = await createTestLibraryItem() + const clip = await Database.audioClipModel.createClip(user.id, libraryItem.id, 10, 20, 'Test') + + const req = { + user, + params: { clipId: clip.id } + } + const res = { + sendStatus: sinon.spy(), + status: sinon.stub().returnsThis(), + send: sinon.spy() + } + + await MeController.deleteClip(req, res) + + expect(res.sendStatus.calledWith(200)).to.be.true + expect(socketEmitStub.calledWith(user.id, 'clip_removed')).to.be.true + + // Verify clip was deleted + const deletedClip = await Database.audioClipModel.findByPk(clip.id) + expect(deletedClip).to.be.null + }) + + it('should return 404 for non-existent clip', async () => { + const user = await Database.userModel.create({ + username: 'testuser', + pash: 'test', + type: 'user', + isActive: true + }) + + const req = { + user, + params: { clipId: 'nonexistent-id' } + } + const res = { + sendStatus: sinon.spy() + } + + await MeController.deleteClip(req, res) + + expect(res.sendStatus.calledWith(404)).to.be.true + }) + + it('should return 403 if clip belongs to another user', async () => { + const user1 = await Database.userModel.create({ + username: 'user1', + pash: 'test', + type: 'user', + isActive: true + }) + + const user2 = await Database.userModel.create({ + username: 'user2', + pash: 'test', + type: 'user', + isActive: true + }) + + const libraryItem = await createTestLibraryItem() + const clip = await Database.audioClipModel.createClip(user1.id, libraryItem.id, 10, 20, 'Test') + + const req = { + user: user2, + params: { clipId: clip.id } + } + const res = { + sendStatus: sinon.spy() + } + + await MeController.deleteClip(req, res) + + expect(res.sendStatus.calledWith(403)).to.be.true + }) + + it('should handle errors gracefully', async () => { + const user = await Database.userModel.create({ + username: 'testuser', + pash: 'test', + type: 'user', + isActive: true + }) + + const libraryItem = await createTestLibraryItem() + const clip = await Database.audioClipModel.createClip(user.id, libraryItem.id, 10, 20, 'Test') + + // Simulate database error + sinon.stub(Database.audioClipModel, 'deleteClip').rejects(new Error('Database error')) + + const req = { + user, + params: { clipId: clip.id } + } + const res = { + sendStatus: sinon.spy(), + status: sinon.stub().returnsThis(), + send: sinon.spy() + } + + await MeController.deleteClip(req, res) + + expect(res.status.calledWith(500)).to.be.true + expect(res.send.calledWith('Failed to delete clip')).to.be.true + }) + }) + + // Helper function to create a test library item + async function createTestLibraryItem() { + const library = await Database.libraryModel.create({ + name: 'Test Library', + mediaType: 'book' + }) + + const libraryFolder = await Database.libraryFolderModel.create({ + path: '/test', + libraryId: library.id + }) + + const book = await Database.bookModel.create({ + title: 'Test Book', + audioFiles: [], + tags: [], + narrators: [], + genres: [], + chapters: [] + }) + + return await Database.libraryItemModel.create({ + libraryFiles: [], + mediaId: book.id, + mediaType: 'book', + libraryId: library.id, + libraryFolderId: libraryFolder.id + }) + } +})