mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-02-28 21:19:42 +00:00
Merge cdb0bbb4d2 into 1d0b7e383a
This commit is contained in:
commit
7f00696f35
8 changed files with 2202 additions and 0 deletions
|
|
@ -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 })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
171
server/migrations/v2.31.0-audio-clips.js
Normal file
171
server/migrations/v2.31.0-audio-clips.js
Normal file
|
|
@ -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<void>} - 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<void>} - 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 }
|
||||
329
server/models/AudioClip.js
Normal file
329
server/models/AudioClip.js
Normal file
|
|
@ -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<AudioClip>}
|
||||
*/
|
||||
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<AudioClip>}
|
||||
*/
|
||||
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<boolean>}
|
||||
*/
|
||||
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<AudioClip[]>}
|
||||
*/
|
||||
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<AudioClip[]>}
|
||||
*/
|
||||
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
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
806
test/server/controllers/MeController.clips.test.js
Normal file
806
test/server/controllers/MeController.clips.test.js
Normal file
|
|
@ -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
|
||||
})
|
||||
}
|
||||
})
|
||||
284
test/server/migrations/v2.31.0-audio-clips.test.js
Normal file
284
test/server/migrations/v2.31.0-audio-clips.test.js
Normal file
|
|
@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
435
test/server/models/AudioClip.test.js
Normal file
435
test/server/models/AudioClip.test.js
Normal file
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue