Make "want read" button work

This commit is contained in:
2025-11-06 15:53:24 +01:00
parent 6bfd3ae2da
commit a8f83db83f
14 changed files with 268 additions and 100 deletions

View File

@@ -12,11 +12,12 @@ import (
)
type fetchedBook struct {
Title string `json:"title" binding:"required,max=300"`
Author string `json:"author" binding:"max=100"`
Summary string `json:"summary"`
Rating int `json:"rating"`
Read bool `json:"read"`
Title string `json:"title" binding:"required,max=300"`
Author string `json:"author" binding:"max=100"`
Summary string `json:"summary"`
Rating int `json:"rating"`
Read bool `json:"read"`
WantRead bool `json:"wantread"`
}
func TestGetBook_Ok(t *testing.T) {

View File

@@ -14,11 +14,12 @@ import (
)
type bookUserGet struct {
BookId uint `json:"id"`
Title string `json:"title" binding:"required,max=300"`
Author string `json:"author" binding:"max=100"`
Rating int `json:"rating" binding:"min=0,max=10"`
Read bool `json:"read"`
BookId uint `json:"id"`
Title string `json:"title" binding:"required,max=300"`
Author string `json:"author" binding:"max=100"`
Rating int `json:"rating" binding:"min=0,max=10"`
Read bool `json:"read"`
WantRead bool `json:"wantread"`
}
func TestGetBooksHandler_Demo(t *testing.T) {

View File

@@ -0,0 +1,25 @@
package apitest
import (
"net/http"
"testing"
"git.artlef.fr/PersonalLibraryManager/internal/testutils"
"github.com/stretchr/testify/assert"
)
func TestPutReadUserBooks_NewReadOk(t *testing.T) {
payload :=
`{
"read": true
}`
bookId := "21"
testPutReadUserBooks(t, payload, bookId, http.StatusOK)
book := testGetBook(t, bookId, http.StatusOK)
assert.Equal(t, true, book.Read)
assert.Equal(t, false, book.WantRead)
}
func testPutReadUserBooks(t *testing.T, payload string, bookId string, expectedCode int) {
testutils.TestBookPutCallWithDemoPayload(t, payload, bookId, expectedCode, "/book/"+bookId+"/read")
}

View File

@@ -1,90 +1,74 @@
package apitest
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.artlef.fr/PersonalLibraryManager/internal/testutils"
"github.com/stretchr/testify/assert"
)
func TestPutBookUpdate_NewReadOk(t *testing.T) {
payload :=
`{
"read": true
}`
testPutUserBooksHandler(t, payload, "21", http.StatusOK)
}
func TestPutUserBooksHandler_UpdateRating(t *testing.T) {
func TestPutRatingUserBooksHandler_UpdateRating(t *testing.T) {
payload :=
`{
"rating": 5
}`
bookId := "17"
testPutUserBooksHandler(t, payload, bookId, http.StatusOK)
testPutRateUserBooks(t, payload, bookId, http.StatusOK)
book := testGetBook(t, bookId, http.StatusOK)
assert.Equal(t, 5, book.Rating)
assert.Equal(t, true, book.Read)
}
func TestPutUserBooksHandler_RateNewBookMakeItRead(t *testing.T) {
func TestPutRatingUserBooksHandler_RateNewBookMakeItRead(t *testing.T) {
payload :=
`{
"rating": 7
}`
bookId := "18"
testPutUserBooksHandler(t, payload, bookId, http.StatusOK)
testPutRateUserBooks(t, payload, bookId, http.StatusOK)
book := testGetBook(t, bookId, http.StatusOK)
assert.Equal(t, 7, book.Rating)
assert.Equal(t, true, book.Read)
assert.Equal(t, false, book.WantRead)
}
func TestPutUserBooksHandler_RatingTypeWrong(t *testing.T) {
func TestPutRatingUserBooksHandler_RatingTypeWrong(t *testing.T) {
payload :=
`{
"rating": "bad"
}`
bookId := "18"
testPutUserBooksHandler(t, payload, bookId, http.StatusInternalServerError)
testPutRateUserBooks(t, payload, bookId, http.StatusInternalServerError)
}
func TestPutUserBooksHandler_RatingMin(t *testing.T) {
func TestPutRatingUserBooksHandler_RatingMin(t *testing.T) {
payload :=
`{
"rating": -3
}`
bookId := "18"
testPutUserBooksHandler(t, payload, bookId, http.StatusBadRequest)
testPutRateUserBooks(t, payload, bookId, http.StatusBadRequest)
}
func TestPutUserBooksHandler_RatingMax(t *testing.T) {
func TestPutRatingUserBooksHandler_RatingMax(t *testing.T) {
payload :=
`{
"rating": 15
}`
bookId := "18"
testPutUserBooksHandler(t, payload, bookId, http.StatusBadRequest)
testPutRateUserBooks(t, payload, bookId, http.StatusBadRequest)
}
func TestPutUserBooksHandler_BadBookId(t *testing.T) {
func TestPutRatingUserBooksHandler_BadBookId(t *testing.T) {
payload :=
`{
"rating": 15
}`
bookId := "18574"
testPutUserBooksHandler(t, payload, bookId, http.StatusNotFound)
testPutRateUserBooks(t, payload, bookId, http.StatusNotFound)
}
func testPutUserBooksHandler(t *testing.T, payload string, bookId string, expectedCode int) {
router := testutils.TestSetup()
token := testutils.ConnectDemoUser(router)
req, _ := http.NewRequest("PUT", "/book/"+bookId, strings.NewReader(payload))
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, expectedCode, w.Code)
func testPutRateUserBooks(t *testing.T, payload string, bookId string, expectedCode int) {
testutils.TestBookPutCallWithDemoPayload(t, payload, bookId, expectedCode, "/book/"+bookId+"/rate")
}

View File

@@ -0,0 +1,35 @@
package apitest
import (
"net/http"
"testing"
"git.artlef.fr/PersonalLibraryManager/internal/testutils"
"github.com/stretchr/testify/assert"
)
func TestPutWantRead_SetTrue(t *testing.T) {
payload :=
`{
"wantread": true
}`
bookId := "17"
testPutWantReadUserBooks(t, payload, bookId, http.StatusOK)
book := testGetBook(t, bookId, http.StatusOK)
assert.Equal(t, true, book.WantRead)
}
func TestPutWantRead_SetFalse(t *testing.T) {
payload :=
`{
"wantread": false
}`
bookId := "2"
testPutWantReadUserBooks(t, payload, bookId, http.StatusOK)
book := testGetBook(t, bookId, http.StatusOK)
assert.Equal(t, false, book.WantRead)
}
func testPutWantReadUserBooks(t *testing.T, payload string, bookId string, expectedCode int) {
testutils.TestBookPutCallWithDemoPayload(t, payload, bookId, expectedCode, "/book/"+bookId+"/wantread")
}

View File

@@ -5,9 +5,10 @@ import "gorm.io/gorm"
// describes the relationship between a user and a book.
type UserBook struct {
gorm.Model
UserID uint
BookID uint
Book Book
Rating int
Read bool
UserID uint
BookID uint
Book Book
Rating int
Read bool
WantRead bool
}

View File

@@ -14,13 +14,14 @@ type BookGet struct {
Summary string `json:"summary"`
Rating int `json:"rating"`
Read bool `json:"read"`
WantRead bool `json:"wantread"`
CoverPath string `json:"coverPath"`
}
func FetchBookGet(db *gorm.DB, userId uint, bookId uint64) (BookGet, error) {
var book BookGet
query := db.Model(&model.Book{})
query = query.Select("books.title, books.author, books.summary, user_books.rating, user_books.read, " + selectStaticFilesPath())
query = query.Select("books.title, books.author, books.summary, user_books.rating, user_books.read, user_books.want_read, " + selectStaticFilesPath())
query = query.Joins("left join user_books on (user_books.book_id = books.id and user_books.user_id = ?)", userId)
query = query.Joins("left join static_files on (static_files.id = books.cover_id)")
query = query.Where("books.id = ?", bookId)
@@ -51,13 +52,14 @@ type BookUserGet struct {
Author string `json:"author" binding:"max=100"`
Rating int `json:"rating" binding:"min=0,max=10"`
Read bool `json:"read" binding:"boolean"`
WantRead bool `json:"wantread" binding:"boolean"`
CoverPath string `json:"coverPath"`
}
func FetchBookUserGet(db *gorm.DB, userId uint) ([]BookUserGet, error) {
var books []BookUserGet
query := db.Model(&model.UserBook{})
query = query.Select("books.id, books.title, books.author, user_books.rating, user_books.read," + selectStaticFilesPath())
query = query.Select("books.id, books.title, books.author, user_books.rating, user_books.read, user_books.want_read, " + selectStaticFilesPath())
query = query.Joins("left join books on (books.id = user_books.book_id)")
query = query.Joins("left join static_files on (static_files.id = books.cover_id)")
query = query.Where("user_id = ?", userId)

View File

@@ -12,69 +12,150 @@ import (
"gorm.io/gorm"
)
type userbookPutUpdate struct {
Read bool `json:"read"`
Rating int `json:"rating" binding:"min=0,max=10"`
func PutReadUserBookHandler(ac appcontext.AppContext) {
data, err := retrieveDataFromContext(ac)
if err != nil {
return
}
bookId := data.BookId
user := data.User
var read userbookPutRead
err = ac.C.ShouldBindJSON(&read)
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
userbook, err := fetchOrCreateUserBook(ac, bookId, &user)
if err != nil {
return
}
userbook.Read = read.Read
//remove the book from "wanted" list when it is marked as read.
if userbook.Read {
userbook.WantRead = false
}
ac.Db.Save(&userbook)
ac.C.String(http.StatusOK, "Success")
}
func PutUserBookHandler(ac appcontext.AppContext) {
func PutWantReadUserBookHandler(ac appcontext.AppContext) {
data, err := retrieveDataFromContext(ac)
if err != nil {
return
}
bookId := data.BookId
user := data.User
var wantread userbookPutWantRead
err = ac.C.ShouldBindJSON(&wantread)
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
userbook, err := fetchOrCreateUserBook(ac, bookId, &user)
if err != nil {
return
}
userbook.WantRead = wantread.WantRead
ac.Db.Save(&userbook)
ac.C.String(http.StatusOK, "Success")
}
func PutRateUserBookHandler(ac appcontext.AppContext) {
data, err := retrieveDataFromContext(ac)
if err != nil {
return
}
bookId := data.BookId
user := data.User
var rating userbookPutRating
err = ac.C.ShouldBindJSON(&rating)
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
userbook, err := fetchOrCreateUserBook(ac, bookId, &user)
if err != nil {
return
}
userbook.Rating = rating.Rating
//if rated, set to "read" (a rating = 0 means unrated)
if userbook.Rating > 0 {
userbook.Read = true
}
ac.Db.Save(&userbook)
ac.C.String(http.StatusOK, "Success")
}
type userbookPutRead struct {
Read bool `json:"read"`
}
type userbookPutWantRead struct {
WantRead bool `json:"wantread"`
}
type userbookPutRating struct {
Rating int `json:"rating" binding:"min=0,max=10"`
}
type apiCallData struct {
BookId uint
User model.User
}
func retrieveDataFromContext(ac appcontext.AppContext) (apiCallData, error) {
bookId64, err := strconv.ParseUint(ac.C.Param("id"), 10, 64)
bookId := uint(bookId64)
if err != nil {
ac.C.JSON(http.StatusBadRequest, gin.H{"error": err})
return
return apiCallData{}, err
}
err = myvalidator.ValidateId(ac.Db, bookId, &model.Book{})
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
var userbook userbookPutUpdate
err = ac.C.ShouldBindJSON(&userbook)
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
return apiCallData{}, err
}
user, fetchUserErr := ac.GetAuthenticatedUser()
if fetchUserErr != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
return apiCallData{}, fetchUserErr
}
return apiCallData{BookId: bookId, User: user}, nil
}
//a rating of 0 means no rating
// if there is a rating, read is forced to true
userbook.Read = userbook.Read || userbook.Rating > 0
var userbookDb model.UserBook
res := ac.Db.Where("user_id = ? AND book_id = ?", user.ID, bookId).First(&userbookDb)
err = res.Error
func fetchOrCreateUserBook(ac appcontext.AppContext, bookId uint, user *model.User) (model.UserBook, error) {
var userbook model.UserBook
res := ac.Db.Where("user_id = ? AND book_id = ?", user.ID, bookId).First(&userbook)
err := res.Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
userbookDb = userBookWsToDb(userbook, bookId, &user)
err = ac.Db.Save(&userbookDb).Error
userbook = createUserBook(bookId, user)
err = ac.Db.Save(&userbook).Error
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
return userbook, err
}
return userbook, nil
} else {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
return userbook, err
}
} else {
userbookDb.Read = userbook.Read
if userbook.Rating > 0 {
userbookDb.Rating = userbook.Rating
}
ac.Db.Save(&userbookDb)
return userbook, nil
}
ac.C.String(http.StatusOK, "Success")
}
func userBookWsToDb(ub userbookPutUpdate, bookId uint, user *model.User) model.UserBook {
func createUserBook(bookId uint, user *model.User) model.UserBook {
return model.UserBook{
UserID: user.ID,
BookID: bookId,
Read: ub.Read,
Rating: ub.Rating,
UserID: user.ID,
BookID: bookId,
Read: false,
WantRead: false,
Rating: 0,
}
}

View File

@@ -33,8 +33,14 @@ func Setup(config *config.Config) *gin.Engine {
r.GET("/book/:id", func(c *gin.Context) {
routes.GetBookHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config})
})
r.PUT("/book/:id", func(c *gin.Context) {
routes.PutUserBookHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config})
r.PUT("/book/:id/read", func(c *gin.Context) {
routes.PutReadUserBookHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config})
})
r.PUT("/book/:id/wantread", func(c *gin.Context) {
routes.PutWantReadUserBookHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config})
})
r.PUT("/book/:id/rate", func(c *gin.Context) {
routes.PutRateUserBookHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config})
})
r.POST("/book", func(c *gin.Context) {
routes.PostBookHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config})

View File

@@ -2,14 +2,17 @@ package testutils
import (
"encoding/json"
"fmt"
"log"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.artlef.fr/PersonalLibraryManager/internal/config"
"git.artlef.fr/PersonalLibraryManager/internal/setup"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
)
func TestSetup() *gin.Engine {
@@ -51,3 +54,13 @@ func connectUser(router *gin.Engine, loginJson string) string {
}
return parsedResponse.Token
}
func TestBookPutCallWithDemoPayload(t *testing.T, payload string, bookId string, expectedCode int, url string) {
router := TestSetup()
token := ConnectDemoUser(router)
req, _ := http.NewRequest("PUT", url, strings.NewReader(payload))
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, expectedCode, w.Code)
}