audiobookshelf/test/server/controllers/MeController.clips.test.js
Jozsef Kiraly cdb0bbb4d2
feat: Add AudioClip to database proper
Also: API endpoints and controller implementations
2025-11-27 11:55:52 +00:00

806 lines
22 KiB
JavaScript

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