mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-03-01 05:29:41 +00:00
Add review and rating features with sorting and filtering options
- Implemented a new ReviewController to handle review creation, updates, and retrieval for library items. - Added pagination, sorting, and filtering capabilities for reviews in the API. - Updated frontend components to support review display, including a new ReviewsTable and enhanced ratings UI. - Introduced new strings for user interface elements related to reviews and ratings. - Added tests for the ReviewController and Review model to ensure functionality and validation. - Enabled the option to toggle the review feature in server settings.
This commit is contained in:
parent
e4e2770fbd
commit
41e8906312
12 changed files with 603 additions and 43 deletions
|
|
@ -30,6 +30,12 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
/**
|
||||
* A modal for writing or editing a review.
|
||||
* Managed via the 'globals' Vuex store.
|
||||
*
|
||||
* @emit review-updated - Emits the new/updated review object on the root event bus.
|
||||
*/
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -42,8 +42,13 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
/**
|
||||
* A table component to display reviews for a specific library item.
|
||||
* Listens for global 'review-updated' events to refresh the view locally.
|
||||
*/
|
||||
export default {
|
||||
props: {
|
||||
/** The library item object to show reviews for */
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
required: true
|
||||
|
|
|
|||
|
|
@ -7,16 +7,25 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
/**
|
||||
* A reusable 5-star rating component.
|
||||
* Supports read-only display and interactive rating selection.
|
||||
*
|
||||
* @emit input - Emits the selected rating (1-5)
|
||||
*/
|
||||
export default {
|
||||
props: {
|
||||
/** The current rating value (1-5) */
|
||||
value: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
/** If true, the rating cannot be changed */
|
||||
readonly: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
/** The size of the stars in pixels */
|
||||
size: {
|
||||
type: Number,
|
||||
default: 24
|
||||
|
|
|
|||
|
|
@ -1,55 +1,109 @@
|
|||
<template>
|
||||
<div id="page-wrapper" class="bg-bg page overflow-hidden">
|
||||
<div class="w-full h-full overflow-y-auto px-4 py-6 md:p-8">
|
||||
<div class="max-w-5xl mx-auto">
|
||||
<div class="flex items-center mb-8">
|
||||
<span class="material-symbols text-4xl text-yellow-400 mr-4">star</span>
|
||||
<h1 class="text-3xl font-semibold">{{ $strings.ButtonRatings }}</h1>
|
||||
</div>
|
||||
<!-- Toolbar -->
|
||||
<div class="w-full bg-primary border-b border-black-300 px-4 py-2 flex items-center gap-4 flex-wrap z-10">
|
||||
<div class="flex items-center gap-2 mr-4">
|
||||
<span class="material-symbols text-2xl text-yellow-400">star</span>
|
||||
<h1 class="text-xl font-semibold whitespace-nowrap">{{ $strings.ButtonRatings }}</h1>
|
||||
</div>
|
||||
|
||||
<!-- Sort Dropdown -->
|
||||
<ui-dropdown :value="selectedSort" :items="sortItems" small outlined label-key="label" class="w-48" @input="onSortInput" />
|
||||
|
||||
<!-- Filter by User -->
|
||||
<ui-dropdown :value="selectedUserFilter" :items="userFilterItems" small outlined label-key="label" class="w-48" @input="onUserFilterInput" />
|
||||
|
||||
<!-- Filter by Rating -->
|
||||
<ui-dropdown :value="selectedRatingFilter" :items="ratingFilterItems" small outlined label-key="label" class="w-32" @input="onRatingFilterInput" />
|
||||
|
||||
<!-- Search -->
|
||||
<div class="flex-grow max-w-sm ml-auto">
|
||||
<ui-text-input v-model="searchQuery" small :placeholder="$strings.PlaceholderSearchReviews" clearable />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full h-full overflow-y-auto px-4 py-6 md:p-8 pb-32">
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<div v-if="loading" class="flex justify-center py-20">
|
||||
<widgets-loading-spinner />
|
||||
</div>
|
||||
|
||||
<div v-else-if="!reviews.length" class="text-center py-20 text-gray-400 italic">
|
||||
<p class="text-xl mb-2">{{ $strings.LabelNoReviews }}</p>
|
||||
<p>{{ $strings.MessageGoRateBooks }}</p>
|
||||
<p v-if="!searchQuery && !selectedUserFilter && !selectedRatingFilter">{{ $strings.MessageGoRateBooks }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 gap-6">
|
||||
<div v-for="review in reviews" :key="review.id" class="bg-primary/40 rounded-xl overflow-hidden flex flex-col md:flex-row hover:bg-primary/60 transition-colors border border-white/5">
|
||||
<div class="w-full md:w-32 h-48 md:h-auto flex-shrink-0 relative group cursor-pointer" @click="goToItem(review.libraryItem)">
|
||||
<covers-book-cover :library-item="review.libraryItem" :width="128" />
|
||||
</div>
|
||||
|
||||
<div class="p-6 flex-grow flex flex-col">
|
||||
<div class="flex flex-col md:flex-row md:items-start justify-between mb-4 gap-2">
|
||||
<div>
|
||||
<nuxt-link :to="`/item/${review.libraryItemId}`" class="text-xl font-semibold hover:underline text-gray-100">
|
||||
{{ review.libraryItem.media.metadata.title }}
|
||||
</nuxt-link>
|
||||
<p class="text-gray-400 text-sm">
|
||||
{{ review.libraryItem.media.metadata.authorName }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col items-end">
|
||||
<ui-star-rating :value="review.rating" readonly :size="20" />
|
||||
<p class="text-xs text-gray-500 mt-1">{{ $formatDate(review.createdAt, dateFormat) }}</p>
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<!-- Table Header -->
|
||||
<div class="hidden md:flex items-center px-4 py-2 text-xs font-bold uppercase tracking-wider text-gray-500 border-b border-white/5">
|
||||
<div class="w-12"></div> <!-- Cover -->
|
||||
<div class="flex-grow px-4">{{ $strings.LabelBook }}</div>
|
||||
<div class="w-32 px-4">{{ $strings.LabelUser }}</div>
|
||||
<div class="w-32 px-4 text-center">{{ $strings.LabelRating }}</div>
|
||||
<div class="w-24 text-right">{{ $strings.LabelDate }}</div>
|
||||
<div class="w-10"></div> <!-- Actions -->
|
||||
</div>
|
||||
|
||||
<!-- Review Rows -->
|
||||
<div v-for="review in filteredReviews" :key="review.id" class="bg-primary/20 rounded-lg hover:bg-primary/40 transition-colors border border-white/5 overflow-hidden">
|
||||
<div class="flex items-center p-2 md:p-3 gap-4">
|
||||
<!-- Cover -->
|
||||
<div class="w-12 h-18 flex-shrink-0 cursor-pointer" @click="goToItem(review.libraryItem)">
|
||||
<covers-book-cover :library-item="review.libraryItem" :width="48" />
|
||||
</div>
|
||||
|
||||
<p v-if="review.reviewText" class="text-gray-200 text-sm leading-relaxed whitespace-pre-wrap flex-grow italic bg-black/20 p-4 rounded-lg border border-white/5">
|
||||
"{{ review.reviewText }}"
|
||||
</p>
|
||||
<!-- Title/Author -->
|
||||
<div class="flex-grow min-w-0">
|
||||
<nuxt-link :to="`/item/${review.libraryItemId}`" class="font-semibold truncate block hover:underline text-gray-100">
|
||||
{{ getTitle(review) }}
|
||||
</nuxt-link>
|
||||
<p class="text-xs text-gray-400 truncate">{{ getAuthor(review) }}</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex justify-end">
|
||||
<ui-btn small outlined @click="editReview(review)">
|
||||
{{ $strings.ButtonEdit }}
|
||||
<!-- Username -->
|
||||
<div class="hidden md:block w-32 px-4 truncate text-sm text-gray-300">
|
||||
{{ review.user ? review.user.username : 'Unknown' }}
|
||||
</div>
|
||||
|
||||
<!-- Rating -->
|
||||
<div class="w-32 flex flex-col items-center flex-shrink-0">
|
||||
<ui-star-rating :value="review.rating" readonly :size="16" />
|
||||
</div>
|
||||
|
||||
<!-- Date (Desktop) -->
|
||||
<div class="hidden md:block w-24 text-right text-xs text-gray-500">
|
||||
{{ $formatDate(review.createdAt, dateFormat) }}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="w-10 flex justify-end">
|
||||
<ui-btn v-if="isReviewAuthor(review)" icon small flat @click.stop="editReview(review)">
|
||||
<span class="material-symbols text-lg">edit</span>
|
||||
</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Review Text (if exists) -->
|
||||
<div v-if="review.reviewText" class="px-4 md:px-16 pb-3 pt-1">
|
||||
<div class="text-sm text-gray-300 italic bg-black/20 p-3 rounded border border-white/5 relative group">
|
||||
<span class="line-clamp-2 group-hover:line-clamp-none transition-all duration-300 whitespace-pre-wrap">
|
||||
"{{ review.reviewText }}"
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="totalReviews > limit" class="mt-8 flex justify-center gap-4 items-center pb-8">
|
||||
<ui-btn small :disabled="page === 0" @click="changePage(page - 1)">
|
||||
<span class="material-symbols">chevron_left</span>
|
||||
</ui-btn>
|
||||
<span class="text-sm text-gray-400">Page {{ page + 1 }} of {{ Math.ceil(totalReviews / limit) }}</span>
|
||||
<ui-btn small :disabled="page >= Math.ceil(totalReviews / limit) - 1" @click="changePage(page + 1)">
|
||||
<span class="material-symbols">chevron_right</span>
|
||||
</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -67,7 +121,15 @@ export default {
|
|||
data() {
|
||||
return {
|
||||
reviews: [],
|
||||
loading: true
|
||||
totalReviews: 0,
|
||||
loading: true,
|
||||
selectedSort: 'newest',
|
||||
selectedUserFilter: null,
|
||||
selectedRatingFilter: null,
|
||||
searchQuery: '',
|
||||
page: 0,
|
||||
limit: 50,
|
||||
users: []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
|
|
@ -76,24 +138,108 @@ export default {
|
|||
},
|
||||
dateFormat() {
|
||||
return this.$store.getters['getServerSetting']('dateFormat')
|
||||
},
|
||||
currentUser() {
|
||||
return this.$store.state.user.user
|
||||
},
|
||||
sortItems() {
|
||||
return [
|
||||
{ value: 'newest', label: this.$strings.LabelSortNewestFirst },
|
||||
{ value: 'oldest', label: this.$strings.LabelSortOldestFirst },
|
||||
{ value: 'highest', label: this.$strings.LabelSortHighestRated },
|
||||
{ value: 'lowest', label: this.$strings.LabelSortLowestRated }
|
||||
]
|
||||
},
|
||||
userFilterItems() {
|
||||
const items = [{ value: null, label: this.$strings.LabelAllUsers }]
|
||||
this.users.forEach((u) => {
|
||||
items.push({ value: u.id, label: u.username })
|
||||
})
|
||||
return items
|
||||
},
|
||||
ratingFilterItems() {
|
||||
const items = [{ value: null, label: this.$strings.LabelAllReviews }]
|
||||
for (let i = 5; i >= 1; i--) {
|
||||
items.push({ value: i, label: `${i} ${i === 1 ? 'Star' : 'Stars'}` })
|
||||
}
|
||||
return items
|
||||
},
|
||||
filteredReviews() {
|
||||
if (!this.searchQuery) return this.reviews
|
||||
const q = this.searchQuery.toLowerCase()
|
||||
return this.reviews.filter((r) => {
|
||||
const title = this.getTitle(r).toLowerCase()
|
||||
const author = this.getAuthor(r).toLowerCase()
|
||||
return title.includes(q) || author.includes(q)
|
||||
})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async fetchUsers() {
|
||||
if (!this.currentUser.isAdminOrUp) return
|
||||
try {
|
||||
const data = await this.$axios.$get('/api/users')
|
||||
this.users = data.users || []
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch users', error)
|
||||
}
|
||||
},
|
||||
async fetchReviews() {
|
||||
this.loading = true
|
||||
try {
|
||||
const reviews = await this.$axios.$get('/api/me/reviews')
|
||||
// Filter by current library
|
||||
this.reviews = reviews.filter((r) => r.libraryItem && r.libraryItem.libraryId === this.libraryId)
|
||||
const params = {
|
||||
sort: this.selectedSort,
|
||||
limit: this.limit,
|
||||
page: this.page
|
||||
}
|
||||
if (this.selectedUserFilter) {
|
||||
params.filter = `user.${this.selectedUserFilter}`
|
||||
} else if (this.selectedRatingFilter) {
|
||||
params.filter = `rating.${this.selectedRatingFilter}`
|
||||
}
|
||||
|
||||
const data = await this.$axios.$get(`/api/libraries/${this.libraryId}/reviews`, { params })
|
||||
this.reviews = data.reviews
|
||||
this.totalReviews = data.total
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch user reviews', error)
|
||||
console.error('Failed to fetch library reviews', error)
|
||||
this.$toast.error('Failed to fetch reviews')
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
onSortInput(val) {
|
||||
this.selectedSort = val
|
||||
this.page = 0
|
||||
this.fetchReviews()
|
||||
},
|
||||
onUserFilterInput(val) {
|
||||
this.selectedUserFilter = val
|
||||
this.selectedRatingFilter = null
|
||||
this.page = 0
|
||||
this.fetchReviews()
|
||||
},
|
||||
onRatingFilterInput(val) {
|
||||
this.selectedRatingFilter = val
|
||||
this.selectedUserFilter = null
|
||||
this.page = 0
|
||||
this.fetchReviews()
|
||||
},
|
||||
changePage(newPage) {
|
||||
this.page = newPage
|
||||
this.fetchReviews()
|
||||
},
|
||||
getTitle(review) {
|
||||
return review.libraryItem?.media?.metadata?.title || 'Unknown Title'
|
||||
},
|
||||
getAuthor(review) {
|
||||
return review.libraryItem?.media?.metadata?.authorName || 'Unknown Author'
|
||||
},
|
||||
goToItem(item) {
|
||||
this.$router.push(`/item/${item.id}`)
|
||||
if (item) this.$router.push(`/item/${item.id}`)
|
||||
},
|
||||
isReviewAuthor(review) {
|
||||
return review.userId === this.currentUser.id
|
||||
},
|
||||
editReview(review) {
|
||||
this.$store.commit('globals/setReviewModal', {
|
||||
|
|
@ -103,6 +249,7 @@ export default {
|
|||
}
|
||||
},
|
||||
mounted() {
|
||||
this.fetchUsers()
|
||||
this.fetchReviews()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -241,10 +241,19 @@
|
|||
"LabelAddedDate": "Added {0}",
|
||||
"LabelAdminUsersOnly": "Admin users only",
|
||||
"LabelAll": "All",
|
||||
"LabelAllReviews": "All Reviews",
|
||||
"LabelAllEpisodesDownloaded": "All episodes downloaded",
|
||||
"LabelAllUsers": "All Users",
|
||||
"LabelAllUsersExcludingGuests": "All users excluding guests",
|
||||
"LabelAllUsersIncludingGuests": "All users including guests",
|
||||
"LabelFilterByRating": "Filter by Rating",
|
||||
"LabelFilterByUser": "Filter by User",
|
||||
"LabelSortHighestRated": "Highest Rated",
|
||||
"LabelSortLowestRated": "Lowest Rated",
|
||||
"LabelSortNewestFirst": "Newest First",
|
||||
"LabelSortOldestFirst": "Oldest First",
|
||||
"LabelSortTitleAZ": "Title A-Z",
|
||||
"PlaceholderSearchReviews": "Search by title or author...",
|
||||
"LabelAlreadyInYourLibrary": "Already in your library",
|
||||
"LabelApiKeyCreated": "API Key \"{0}\" created successfully.",
|
||||
"LabelApiKeyCreatedDescription": "Make sure to copy the API key now as you will not be able to see this again.",
|
||||
|
|
|
|||
|
|
@ -387,6 +387,7 @@ class Server {
|
|||
'/library/:library/authors',
|
||||
'/library/:library/narrators',
|
||||
'/library/:library/stats',
|
||||
'/library/:library/ratings',
|
||||
'/library/:library/series/:id?',
|
||||
'/library/:library/podcast/search',
|
||||
'/library/:library/podcast/latest',
|
||||
|
|
|
|||
|
|
@ -180,6 +180,110 @@ class ReviewController {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET: /api/libraries/:id/reviews
|
||||
* Get all reviews for items in a library.
|
||||
* Supports sorting by newest, oldest, highest, lowest.
|
||||
* Supports filtering by user or rating.
|
||||
* Supports pagination with limit and page.
|
||||
*
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
*/
|
||||
async findAllForLibrary(req, res) {
|
||||
if (!Database.serverSettings.enableReviews) {
|
||||
return res.status(403).send('Review feature is disabled')
|
||||
}
|
||||
|
||||
const libraryId = req.params.id
|
||||
const { sort, filter, limit, page } = req.query
|
||||
|
||||
try {
|
||||
const where = {}
|
||||
const include = [
|
||||
{
|
||||
model: Database.userModel,
|
||||
attributes: ['id', 'username']
|
||||
},
|
||||
{
|
||||
model: Database.libraryItemModel,
|
||||
where: { libraryId },
|
||||
required: true,
|
||||
include: [
|
||||
{
|
||||
model: Database.bookModel,
|
||||
include: [
|
||||
{ model: Database.authorModel, through: { attributes: [] } },
|
||||
{ model: Database.seriesModel, through: { attributes: ['id', 'sequence'] } }
|
||||
]
|
||||
},
|
||||
{
|
||||
model: Database.podcastModel,
|
||||
include: { model: Database.podcastEpisodeModel }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
if (filter) {
|
||||
const [filterType, filterValue] = filter.split('.')
|
||||
if (filterType === 'user' && filterValue) {
|
||||
where.userId = filterValue
|
||||
} else if (filterType === 'rating' && filterValue) {
|
||||
where.rating = filterValue
|
||||
}
|
||||
}
|
||||
|
||||
let order = [['createdAt', 'DESC']]
|
||||
if (sort === 'oldest') order = [['createdAt', 'ASC']]
|
||||
else if (sort === 'highest') order = [['rating', 'DESC'], ['createdAt', 'DESC']]
|
||||
else if (sort === 'lowest') order = [['rating', 'ASC'], ['createdAt', 'DESC']]
|
||||
|
||||
const limitNum = limit ? parseInt(limit) : 50
|
||||
const pageNum = page ? parseInt(page) : 0
|
||||
const offset = pageNum * limitNum
|
||||
|
||||
const { count, rows: reviews } = await Database.reviewModel.findAndCountAll({
|
||||
where,
|
||||
include,
|
||||
order,
|
||||
limit: limitNum,
|
||||
offset
|
||||
})
|
||||
|
||||
const results = reviews.map((r) => {
|
||||
const json = r.toOldJSON()
|
||||
if (r.libraryItem) {
|
||||
if (!r.libraryItem.media) {
|
||||
if (r.libraryItem.mediaType === 'book' && r.libraryItem.book) {
|
||||
r.libraryItem.media = r.libraryItem.book
|
||||
} else if (r.libraryItem.mediaType === 'podcast' && r.libraryItem.podcast) {
|
||||
r.libraryItem.media = r.libraryItem.podcast
|
||||
}
|
||||
}
|
||||
if (r.libraryItem.media) {
|
||||
try {
|
||||
json.libraryItem = r.libraryItem.toOldJSONMinified()
|
||||
} catch (err) {
|
||||
Logger.error(`[ReviewController] Failed to minify library item ${r.libraryItem.id}`, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return json
|
||||
})
|
||||
|
||||
res.json({
|
||||
reviews: results,
|
||||
total: count,
|
||||
page: pageNum,
|
||||
limit: limitNum
|
||||
})
|
||||
} catch (error) {
|
||||
Logger.error(`[ReviewController] Failed to fetch reviews for library ${libraryId}`, error)
|
||||
res.status(500).send('Failed to fetch reviews')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware for review routes.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -1,18 +1,32 @@
|
|||
const { DataTypes, Model } = require('sequelize')
|
||||
|
||||
/**
|
||||
* @typedef ReviewJSON
|
||||
* @property {string} id
|
||||
* @property {number} rating
|
||||
* @property {string} reviewText
|
||||
* @property {string} userId
|
||||
* @property {string} libraryItemId
|
||||
* @property {number} updatedAt
|
||||
* @property {number} createdAt
|
||||
* @property {Object} [user]
|
||||
* @property {string} user.id
|
||||
* @property {string} user.username
|
||||
*/
|
||||
|
||||
class Review extends Model {
|
||||
constructor(values, options) {
|
||||
super(values, options)
|
||||
|
||||
/** @type {UUIDV4} */
|
||||
/** @type {string} */
|
||||
this.id
|
||||
/** @type {number} */
|
||||
this.rating
|
||||
/** @type {string} */
|
||||
this.reviewText
|
||||
/** @type {UUIDV4} */
|
||||
/** @type {string} */
|
||||
this.userId
|
||||
/** @type {UUIDV4} */
|
||||
/** @type {string} */
|
||||
this.libraryItemId
|
||||
/** @type {Date} */
|
||||
this.updatedAt
|
||||
|
|
@ -20,6 +34,12 @@ class Review extends Model {
|
|||
this.createdAt
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the Review model and associations.
|
||||
* A user can have only one review per library item.
|
||||
*
|
||||
* @param {import('sequelize').Sequelize} sequelize
|
||||
*/
|
||||
static init(sequelize) {
|
||||
super.init(
|
||||
{
|
||||
|
|
@ -62,6 +82,11 @@ class Review extends Model {
|
|||
Review.belongsTo(libraryItem)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to the old JSON format for the browser.
|
||||
*
|
||||
* @returns {ReviewJSON}
|
||||
*/
|
||||
toOldJSON() {
|
||||
return {
|
||||
id: this.id,
|
||||
|
|
@ -79,4 +104,4 @@ class Review extends Model {
|
|||
}
|
||||
}
|
||||
|
||||
module.exports = Review
|
||||
module.exports = Review
|
||||
|
|
@ -55,7 +55,9 @@ class ServerSettings {
|
|||
this.language = 'en-us'
|
||||
this.allowedOrigins = []
|
||||
|
||||
/** @type {boolean} If true, users can rate and review library items */
|
||||
this.enableReviews = true
|
||||
/** @type {boolean} If true, the Ratings page link is shown in the library sidebar */
|
||||
this.showReviewsInSidebar = true
|
||||
|
||||
this.logLevel = Logger.logLevel
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ class ApiRouter {
|
|||
this.router.get('/libraries/:id/filterdata', LibraryController.middleware.bind(this), LibraryController.getLibraryFilterData.bind(this))
|
||||
this.router.get('/libraries/:id/search', LibraryController.middleware.bind(this), LibraryController.search.bind(this))
|
||||
this.router.get('/libraries/:id/stats', LibraryController.middleware.bind(this), LibraryController.stats.bind(this))
|
||||
this.router.get('/libraries/:id/reviews', LibraryController.middleware.bind(this), ReviewController.findAllForLibrary.bind(this))
|
||||
this.router.get('/libraries/:id/authors', LibraryController.middleware.bind(this), LibraryController.getAuthors.bind(this))
|
||||
this.router.get('/libraries/:id/narrators', LibraryController.middleware.bind(this), LibraryController.getNarrators.bind(this))
|
||||
this.router.patch('/libraries/:id/narrators/:narratorId', LibraryController.middleware.bind(this), LibraryController.updateNarrator.bind(this))
|
||||
|
|
|
|||
154
test/server/controllers/ReviewController.test.js
Normal file
154
test/server/controllers/ReviewController.test.js
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
const { expect } = require('chai')
|
||||
const { Sequelize } = require('sequelize')
|
||||
const sinon = require('sinon')
|
||||
|
||||
const Database = require('../../../server/Database')
|
||||
const ApiRouter = require('../../../server/routers/ApiRouter')
|
||||
const ReviewController = require('../../../server/controllers/ReviewController')
|
||||
const ApiCacheManager = require('../../../server/managers/ApiCacheManager')
|
||||
const Auth = require('../../../server/Auth')
|
||||
const Logger = require('../../../server/Logger')
|
||||
|
||||
describe('ReviewController', () => {
|
||||
/** @type {ApiRouter} */
|
||||
let apiRouter
|
||||
|
||||
beforeEach(async () => {
|
||||
Database.sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })
|
||||
Database.sequelize.uppercaseFirst = (str) => (str ? `${str[0].toUpperCase()}${str.substr(1)}` : '')
|
||||
await Database.buildModels()
|
||||
|
||||
Database.serverSettings = {
|
||||
enableReviews: true
|
||||
}
|
||||
|
||||
apiRouter = new ApiRouter({
|
||||
auth: new Auth(),
|
||||
apiCacheManager: new ApiCacheManager()
|
||||
})
|
||||
|
||||
sinon.stub(Logger, 'info')
|
||||
sinon.stub(Logger, 'error')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
sinon.restore()
|
||||
await Database.sequelize.close()
|
||||
})
|
||||
|
||||
async function createTestLibraryItem() {
|
||||
const library = await Database.libraryModel.create({ name: 'Test', mediaType: 'book' })
|
||||
const book = await Database.bookModel.create({ title: 'Test Book' })
|
||||
return await Database.libraryItemModel.create({ mediaId: book.id, mediaType: 'book', libraryId: library.id })
|
||||
}
|
||||
|
||||
describe('createUpdate', () => {
|
||||
it('should create a new review', async () => {
|
||||
const user = await Database.userModel.create({ username: 'testuser', type: 'root' })
|
||||
const libraryItem = await createTestLibraryItem()
|
||||
|
||||
const fakeReq = {
|
||||
params: { id: libraryItem.id },
|
||||
body: { rating: 5, reviewText: 'Great book!' },
|
||||
user
|
||||
}
|
||||
const fakeRes = {
|
||||
json: sinon.spy(),
|
||||
status: sinon.stub().returns({ send: sinon.spy() })
|
||||
}
|
||||
|
||||
await ReviewController.createUpdate(fakeReq, fakeRes)
|
||||
|
||||
expect(fakeRes.json.calledOnce).to.be.true
|
||||
const review = fakeRes.json.firstCall.args[0]
|
||||
expect(review.rating).to.equal(5)
|
||||
expect(review.reviewText).to.equal('Great book!')
|
||||
expect(review.userId).to.equal(user.id)
|
||||
})
|
||||
|
||||
it('should update an existing review', async () => {
|
||||
const user = await Database.userModel.create({ username: 'testuser', type: 'root' })
|
||||
const libraryItem = await createTestLibraryItem()
|
||||
|
||||
await Database.reviewModel.create({ userId: user.id, libraryItemId: libraryItem.id, rating: 3 })
|
||||
|
||||
const fakeReq = {
|
||||
params: { id: libraryItem.id },
|
||||
body: { rating: 4, reviewText: 'Actually better' },
|
||||
user
|
||||
}
|
||||
const fakeRes = {
|
||||
json: sinon.spy()
|
||||
}
|
||||
|
||||
await ReviewController.createUpdate(fakeReq, fakeRes)
|
||||
|
||||
const review = fakeRes.json.firstCall.args[0]
|
||||
expect(review.rating).to.equal(4)
|
||||
expect(review.reviewText).to.equal('Actually better')
|
||||
})
|
||||
|
||||
it('should return 400 for invalid rating', async () => {
|
||||
const fakeReq = {
|
||||
params: { id: 'some-id' },
|
||||
body: { rating: 6 },
|
||||
user: { id: 'u1' }
|
||||
}
|
||||
const fakeRes = {
|
||||
status: sinon.stub().returns({ send: sinon.spy() })
|
||||
}
|
||||
|
||||
await ReviewController.createUpdate(fakeReq, fakeRes)
|
||||
expect(fakeRes.status.calledWith(400)).to.be.true
|
||||
})
|
||||
})
|
||||
|
||||
describe('findAllForItem', () => {
|
||||
it('should return all reviews for an item', async () => {
|
||||
const user1 = await Database.userModel.create({ username: 'u1', type: 'user' })
|
||||
const user2 = await Database.userModel.create({ username: 'u2', type: 'user' })
|
||||
const libraryItem = await createTestLibraryItem()
|
||||
|
||||
await Database.reviewModel.create({ userId: user1.id, libraryItemId: libraryItem.id, rating: 5 })
|
||||
await Database.reviewModel.create({ userId: user2.id, libraryItemId: libraryItem.id, rating: 4 })
|
||||
|
||||
const fakeReq = { params: { id: libraryItem.id } }
|
||||
const fakeRes = { json: sinon.spy() }
|
||||
|
||||
await ReviewController.findAllForItem(fakeReq, fakeRes)
|
||||
|
||||
expect(fakeRes.json.calledOnce).to.be.true
|
||||
expect(fakeRes.json.firstCall.args[0]).to.have.lengthOf(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('delete', () => {
|
||||
it('should delete a review', async () => {
|
||||
const user = await Database.userModel.create({ username: 'u1', type: 'user' })
|
||||
const libraryItem = await createTestLibraryItem()
|
||||
await Database.reviewModel.create({ userId: user.id, libraryItemId: libraryItem.id, rating: 5 })
|
||||
|
||||
const fakeReq = { params: { id: libraryItem.id }, user }
|
||||
const fakeRes = { sendStatus: sinon.spy() }
|
||||
|
||||
await ReviewController.delete(fakeReq, fakeRes)
|
||||
expect(fakeRes.sendStatus.calledWith(200)).to.be.true
|
||||
|
||||
const count = await Database.reviewModel.count()
|
||||
expect(count).to.equal(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('middleware', () => {
|
||||
it('should block when enableReviews is false', async () => {
|
||||
Database.serverSettings.enableReviews = false
|
||||
const fakeReq = {}
|
||||
const fakeRes = { status: sinon.stub().returns({ send: sinon.spy() }) }
|
||||
const next = sinon.spy()
|
||||
|
||||
await ReviewController.middleware(fakeReq, fakeRes, next)
|
||||
expect(fakeRes.status.calledWith(403)).to.be.true
|
||||
expect(next.called).to.be.false
|
||||
})
|
||||
})
|
||||
})
|
||||
97
test/server/models/Review.test.js
Normal file
97
test/server/models/Review.test.js
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
const { expect } = require('chai')
|
||||
const { Sequelize } = require('sequelize')
|
||||
const Database = require('../../../server/Database')
|
||||
|
||||
describe('Review Model', () => {
|
||||
beforeEach(async () => {
|
||||
Database.sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })
|
||||
await Database.buildModels()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await Database.sequelize.close()
|
||||
})
|
||||
|
||||
async function createTestLibraryItem(id = 'li1') {
|
||||
const library = await Database.libraryModel.create({ name: 'Test', mediaType: 'book' })
|
||||
const book = await Database.bookModel.create({ title: 'Test Book' })
|
||||
return await Database.libraryItemModel.create({ id, mediaId: book.id, mediaType: 'book', libraryId: library.id })
|
||||
}
|
||||
|
||||
it('should validate rating between 1 and 5', async () => {
|
||||
const user = await Database.userModel.create({ username: 'u1' })
|
||||
const item = await createTestLibraryItem('li1')
|
||||
const item2 = await createTestLibraryItem('li2')
|
||||
const item3 = await createTestLibraryItem('li3')
|
||||
|
||||
// Valid
|
||||
await Database.reviewModel.create({ userId: user.id, libraryItemId: item.id, rating: 5 })
|
||||
|
||||
// Invalid - too high
|
||||
let error
|
||||
try {
|
||||
await Database.reviewModel.create({ userId: user.id, libraryItemId: item2.id, rating: 6 })
|
||||
} catch (err) {
|
||||
error = err
|
||||
}
|
||||
expect(error).to.exist
|
||||
expect(error.name).to.equal('SequelizeValidationError')
|
||||
|
||||
// Invalid - too low
|
||||
error = null
|
||||
try {
|
||||
await Database.reviewModel.create({ userId: user.id, libraryItemId: item3.id, rating: 0 })
|
||||
} catch (err) {
|
||||
error = err
|
||||
}
|
||||
expect(error).to.exist
|
||||
expect(error.name).to.equal('SequelizeValidationError')
|
||||
})
|
||||
|
||||
it('should enforce unique constraint on userId and libraryItemId', async () => {
|
||||
const user = await Database.userModel.create({ username: 'u1' })
|
||||
const item = await createTestLibraryItem('li1')
|
||||
|
||||
await Database.reviewModel.create({ userId: user.id, libraryItemId: item.id, rating: 5 })
|
||||
|
||||
let error
|
||||
try {
|
||||
await Database.reviewModel.create({ userId: user.id, libraryItemId: item.id, rating: 4 })
|
||||
} catch (err) {
|
||||
error = err
|
||||
}
|
||||
expect(error).to.exist
|
||||
expect(error.name).to.equal('SequelizeUniqueConstraintError')
|
||||
})
|
||||
|
||||
it('should cascade delete when user is deleted', async () => {
|
||||
const user = await Database.userModel.create({ username: 'u1' })
|
||||
const item = await createTestLibraryItem('li1')
|
||||
await Database.reviewModel.create({ userId: user.id, libraryItemId: item.id, rating: 5 })
|
||||
|
||||
await user.destroy()
|
||||
const count = await Database.reviewModel.count()
|
||||
expect(count).to.equal(0)
|
||||
})
|
||||
|
||||
it('should return correct format in toOldJSON', async () => {
|
||||
const user = await Database.userModel.create({ username: 'testuser' })
|
||||
const item = await createTestLibraryItem('li1')
|
||||
const review = await Database.reviewModel.create({
|
||||
userId: user.id,
|
||||
libraryItemId: item.id,
|
||||
rating: 4,
|
||||
reviewText: 'Nice'
|
||||
})
|
||||
|
||||
// Manually associate user for the test
|
||||
review.user = user
|
||||
|
||||
const json = review.toOldJSON()
|
||||
expect(json.rating).to.equal(4)
|
||||
expect(json.reviewText).to.equal('Nice')
|
||||
expect(json.user.username).to.equal('testuser')
|
||||
expect(json.createdAt).to.be.a('number')
|
||||
expect(json.updatedAt).to.be.a('number')
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue