mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-12-19 10:19:37 +00:00
feat: Add AudioClip to database proper
Also: API endpoints and controller implementations
This commit is contained in:
parent
657cb075ee
commit
cdb0bbb4d2
4 changed files with 983 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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue