feat: Add AudioClip data model

Also: migration and test coverage
This commit is contained in:
Jozsef Kiraly 2025-11-27 11:54:43 +00:00
parent 8758c62ae2
commit 657cb075ee
No known key found for this signature in database
4 changed files with 1219 additions and 0 deletions

View 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
View 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

View 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
})
})
})

View 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')
})
})
})