audiobookshelf/test/server/models/AudioClip.test.js
Jozsef Kiraly 657cb075ee
feat: Add AudioClip data model
Also: migration and test coverage
2025-11-27 11:54:43 +00:00

435 lines
16 KiB
JavaScript

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