diff --git a/app/app.go b/app/app.go
index 8bf1206..58462ca 100644
--- a/app/app.go
+++ b/app/app.go
@@ -355,3 +355,12 @@ func IsUniqueViolation(err error) (column string, ok bool) {
return pgErr.ColumnName, true
}
+
+// Value dereferences a pointer and returns the value, or zero value if nil.
+func Value[T any](p *T) (v T) {
+ if p == nil {
+ return
+ }
+
+ return *p
+}
diff --git a/app/ds/book.go b/app/ds/book.go
index 6f77517..7549728 100644
--- a/app/ds/book.go
+++ b/app/ds/book.go
@@ -117,6 +117,8 @@ func BookFromContext(ctx context.Context) *Book {
// BooksFilter is used to filter and paginate user queries.
type BooksFilter struct {
EntitiesFilter
+
+ Author string
}
// BookAuthor represents an author of a book.
diff --git a/app/ds/ds.go b/app/ds/ds.go
index af5f4eb..4fc1e64 100644
--- a/app/ds/ds.go
+++ b/app/ds/ds.go
@@ -72,11 +72,12 @@ func DtBetween(from, to time.Time) *FilterDT {
// FilterString defines options for filtering string-type data.
type FilterString struct {
- NotNull *bool
- NotEmpty *bool
- ExactMatch *string
- Contains *string
- NotContains *string
- StartsWith *string
- EndsWith *string
+ Null *bool
+ Empty *bool
+ Equal *string
+ Contains *string
+ StartsWith *string
+ EndsWith *string
+ Not bool
+ CaseSensitive bool
}
diff --git a/app/ds/topic.go b/app/ds/topic.go
index a0e0aed..fa97786 100644
--- a/app/ds/topic.go
+++ b/app/ds/topic.go
@@ -28,6 +28,7 @@ type TopicsFilter struct {
Page int
PerPage int
Type EntityType
+ Name *FilterString
PublicIDs []string
WithCount bool
OrderBy string
diff --git a/app/repo/book_repo.go b/app/repo/book_repo.go
index ca51e5a..e9d5423 100644
--- a/app/repo/book_repo.go
+++ b/app/repo/book_repo.go
@@ -128,6 +128,11 @@ func (r *Repo) FilterBooks(ctx context.Context, f ds.BooksFilter) (books []ds.Bo
)`
}
+ var whereAuthor string
+ if f.Author != "" {
+ whereAuthor = `EXISTS (SELECT 1 FROM jsonb_to_recordset(b.authors) AS a(name text) WHERE a.name = ?)`
+ }
+
count, err = r.filter("entities e", "e").
columns(`
e.id AS id,
@@ -152,6 +157,8 @@ func (r *Repo) FilterBooks(ctx context.Context, f ds.BooksFilter) (books []ds.Bo
join("LEFT JOIN users u ON e.owner_id = u.id").
where("e.type", ds.EntityTypeBook).
whereRaw(whereTopics, f.Topics).
+ whereRaw(whereAuthor, f.Author).
+ filterString("e.title", f.Title).
paginate(f.Page, f.PerPage).
createdAt(f.CreatedAt).
deletedAt(f.DeletedAt).
@@ -164,6 +171,11 @@ func (r *Repo) FilterBooks(ctx context.Context, f ds.BooksFilter) (books []ds.Bo
withCount(f.WithCount).
scan(ctx, &books)
+ if err != nil {
+ err = fmt.Errorf("filter books: %w", err)
+ return
+ }
+
// topics
if len(books) > 0 {
ids := make([]ds.ID, len(books))
@@ -196,3 +208,30 @@ func (r *Repo) FilterBooks(ctx context.Context, f ds.BooksFilter) (books []ds.Bo
return
}
+
+// SearchBookAuthors searches book authors by name.
+func (r *Repo) SearchBookAuthors(ctx context.Context, query string) ([]ds.BookAuthor, error) {
+ _, span := r.tracer.Start(ctx, "SearchBookAuthors")
+ defer span.End()
+
+ sql := `
+ SELECT DISTINCT a.name
+ FROM entities e
+ JOIN books b ON b.id = e.id
+ JOIN LATERAL jsonb_to_recordset(b.authors) AS a(name text) ON true
+ WHERE e.type = 'book'
+ AND e.status = 'approved'
+ AND e.visibility = 'public'
+ AND e.deleted_at IS NULL
+ AND a.name ILIKE $1
+ LIMIT 25
+ `
+
+ var rows []ds.BookAuthor
+ err := pgxscan.Select(ctx, r.db, &rows, sql, "%"+query+"%")
+ if err != nil {
+ return nil, fmt.Errorf("search book authors: %w", err)
+ }
+
+ return rows, nil
+}
diff --git a/app/repo/repo.go b/app/repo/repo.go
index 0238e2c..2958807 100644
--- a/app/repo/repo.go
+++ b/app/repo/repo.go
@@ -343,6 +343,61 @@ func (b *filterBuilder) withCount(ok bool) *filterBuilder {
return b
}
+func (b *filterBuilder) filterString(col string, f *ds.FilterString) *filterBuilder {
+ if f == nil {
+ return b
+ }
+
+ if app.Value(f.Null) {
+ if f.Not {
+ b.qb = b.qb.Where(col + " IS NOT NULL")
+ } else {
+ b.qb = b.qb.Where(col + " IS NULL")
+ }
+ }
+
+ if app.Value(f.Empty) {
+ if f.Not {
+ b.qb = b.qb.Where(col + " != ''")
+ } else {
+ b.qb = b.qb.Where(col + " = ''")
+ }
+ }
+
+ if v := app.Value(f.Equal); v != "" {
+ if f.Not {
+ b.qb = b.qb.Where(col+" != ?", v)
+ } else {
+ b.qb = b.qb.Where(col+" = ?", v)
+ }
+ }
+
+ likeOP := "ILIKE"
+ if f.CaseSensitive {
+ likeOP = "LIKE"
+ }
+ if f.Not {
+ likeOP = "NOT " + likeOP
+ }
+
+ if v := app.Value(f.Contains); v != "" {
+ v = "%" + v + "%"
+ b.qb = b.qb.Where(col+" "+likeOP+" ?", v)
+ }
+
+ if v := app.Value(f.StartsWith); v != "" {
+ v += "%"
+ b.qb = b.qb.Where(col+" "+likeOP+" ?", v)
+ }
+
+ if v := app.Value(f.EndsWith); v != "" {
+ v = "%" + v
+ b.qb = b.qb.Where(col+" "+likeOP+" ?", v)
+ }
+
+ return b
+}
+
func (b *filterBuilder) sql() (sql string, args []any, err error) {
lb := *b
if !lb.columnsSet {
diff --git a/app/repo/topic_repo.go b/app/repo/topic_repo.go
index b290298..726120e 100644
--- a/app/repo/topic_repo.go
+++ b/app/repo/topic_repo.go
@@ -2,6 +2,7 @@ package repo
import (
"context"
+ "fmt"
"github.com/georgysavva/scany/v2/pgxscan"
"github.com/gopl-dev/server/app"
@@ -20,17 +21,21 @@ func (r *Repo) FilterTopics(ctx context.Context, f ds.TopicsFilter) (data []ds.T
if f.OrderBy == "" {
f.OrderBy = "name"
- f.OrderDirection = "asc"
+ f.OrderDirection = "ASC"
}
count, err = r.filter("topics").
columns(`*`).
where("type", f.Type).
+ filterString("name", f.Name).
paginate(f.Page, f.PerPage).
order(f.OrderBy, f.OrderDirection).
withCount(f.WithCount).
apply(whereIn("public_id", f.PublicIDs)).
scan(ctx, &data)
+ if err != nil {
+ err = fmt.Errorf("filter topics: %w", err)
+ }
return
}
diff --git a/app/service/book_service.go b/app/service/book_service.go
index 031ecb9..18e0d32 100644
--- a/app/service/book_service.go
+++ b/app/service/book_service.go
@@ -12,6 +12,7 @@ import (
"github.com/gopl-dev/server/app/ds/prop"
"github.com/gopl-dev/server/app/repo"
"github.com/gopl-dev/server/email"
+ "golang.org/x/sync/errgroup"
)
var (
@@ -588,3 +589,98 @@ func (s *Service) GetBookByRef(ctx context.Context, ref any) (*ds.Book, error) {
return nil, ErrInvalidRefID
}
+
+// SearchBookType represents the type of a search result.
+type SearchBookType string
+
+const (
+ // SearchBookTypeBook is a book result.
+ SearchBookTypeBook SearchBookType = "book"
+ // SearchBookTypeAuthor is an author result.
+ SearchBookTypeAuthor SearchBookType = "author"
+ // SearchBookTypeTopic is a topic result.
+ SearchBookTypeTopic SearchBookType = "topic"
+)
+
+// SearchBooksResult represents a single search result item.
+type SearchBooksResult struct {
+ Type SearchBookType `json:"type"`
+ Name string `json:"name"`
+ URL string `json:"url"`
+}
+
+// SearchBooks searches books, books authors and books topics by query string.
+func (s *Service) SearchBooks(ctx context.Context, query string) ([]SearchBooksResult, error) {
+ ctx, span := s.tracer.Start(ctx, "SearchBooks")
+ defer span.End()
+
+ if query == "" {
+ return []SearchBooksResult{}, nil
+ }
+
+ var (
+ books []ds.Book
+ authors []ds.BookAuthor
+ topics []ds.Topic
+ )
+
+ g, ctx := errgroup.WithContext(ctx)
+
+ g.Go(func() (err error) {
+ books, _, err = s.db.FilterBooks(ctx, ds.BooksFilter{EntitiesFilter: ds.EntitiesFilter{
+ PerPage: 25, //nolint:mnd
+ WithCount: false,
+ Title: &ds.FilterString{Contains: &query},
+ Visibility: []ds.EntityVisibility{ds.EntityVisibilityPublic},
+ Status: []ds.EntityStatus{ds.EntityStatusApproved},
+ }})
+ return
+ })
+
+ g.Go(func() (err error) {
+ authors, err = s.db.SearchBookAuthors(ctx, query)
+ return
+ })
+
+ g.Go(func() (err error) {
+ topics, _, err = s.db.FilterTopics(ctx, ds.TopicsFilter{
+ PerPage: 25, //nolint:mnd
+ Type: ds.EntityTypeBook,
+ Name: &ds.FilterString{Contains: &query},
+ })
+ return
+ })
+
+ err := g.Wait()
+ if err != nil {
+ return nil, err
+ }
+
+ results := make([]SearchBooksResult, 0, len(books)+len(authors)+len(topics))
+
+ for _, r := range books {
+ results = append(results, SearchBooksResult{
+ Type: SearchBookTypeBook,
+ Name: r.Title,
+ URL: "/books/" + r.PublicID + "/",
+ })
+ }
+
+ for _, r := range authors {
+ results = append(results, SearchBooksResult{
+ Type: SearchBookTypeAuthor,
+ Name: r.Name,
+ URL: "/books/?author=" + r.Name,
+ })
+ }
+
+ for _, r := range topics {
+ results = append(results, SearchBooksResult{
+ Type: SearchBookTypeTopic,
+ Name: r.Name,
+ URL: "/books/?topics=" + r.PublicID,
+ })
+ }
+
+ return results, nil
+}
diff --git a/frontend/page/filter_books.templ b/frontend/page/filter_books.templ
index e397e5f..b307049 100644
--- a/frontend/page/filter_books.templ
+++ b/frontend/page/filter_books.templ
@@ -15,10 +15,17 @@ templ FilterBooksPage() {
topics: [],
loadedOnce: false,
+ search: '',
+ searchResults: [],
+ searchLoading: false,
+ searchOpen: false,
+ searchDebounce: null,
+
filters: {
page: 1,
per_page: 10,
topic_ids: [],
+ author: "",
},
coverURL(fileID) {
@@ -41,6 +48,9 @@ templ FilterBooksPage() {
const topicIDs = url.searchParams.getAll('topics')
this.filters.topic_ids = topicIDs ?? []
+
+ const author = url.searchParams.get('author')
+ this.filters.author = author ?? ""
},
writeToURL() {
@@ -65,6 +75,41 @@ templ FilterBooksPage() {
this.load({ syncURL: false, scrollTop: true })
},
+ // ---------- search ----------
+
+ async onSearchInput() {
+ clearTimeout(this.searchDebounce)
+ if (this.search.length < 3) {
+ this.searchResults = []
+ this.searchOpen = false
+ return
+ }
+ this.searchDebounce = setTimeout(async () => {
+ this.searchLoading = true
+ try {
+ const { resp, data } = await HTTP.requestJSON(
+ '/api/books/search/?search=' + encodeURIComponent(this.search)
+ )
+ if (resp.status === 200) {
+ this.searchResults = data ?? []
+ this.searchOpen = this.searchResults.length > 0
+ }
+ } catch (e) {
+ console.error(e)
+ } finally {
+ this.searchLoading = false
+ }
+ }, 300)
+ },
+
+ onSearchSelect(result) {
+ window.location.href = result.url
+ },
+
+ closeSearch() {
+ this.searchOpen = false
+ },
+
// ---------- pagination ui ----------
scrollToTop() {
@@ -107,6 +152,7 @@ templ FilterBooksPage() {
const qs = new URLSearchParams({
page: String(this.filters.page),
per_page: String(this.filters.per_page),
+ author: this.filters.author,
})
for (const id of (this.filters.topic_ids ?? [])) {
@@ -185,12 +231,55 @@ templ FilterBooksPage() {
-
-
Books
-
Add book
+
-
-
@@ -239,7 +327,7 @@ templ FilterBooksPage() {
- by
-
-
-
-
-
-
-
-
-
-
-
- ,
-
-
-
- and
-
-
-
-
+ by
+
+
+
+
+
+
+
+
+
+
+
+ ,
+
+
+
+ and
+
+
+
+
-
More...
+
+ More...
+
@@ -355,7 +446,6 @@ templ FilterBooksPage() {
No books found
-
@@ -380,7 +470,7 @@ templ FilterBooksPage() {
-}
+}
\ No newline at end of file
diff --git a/frontend/page/filter_books_templ.go b/frontend/page/filter_books_templ.go
index 90cbb95..91f07ff 100644
--- a/frontend/page/filter_books_templ.go
+++ b/frontend/page/filter_books_templ.go
@@ -31,7 +31,7 @@ func FilterBooksPage() templ.Component {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
Loading…
![]()
by 1 && i < b.authors.length - 2\">, 1 && i === b.authors.length - 2\"> and More...
No books found
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
diff --git a/server/docs/docs.go b/server/docs/docs.go
index 33f3658..a007af8 100644
--- a/server/docs/docs.go
+++ b/server/docs/docs.go
@@ -34,6 +34,11 @@ const docTemplate = `{
"summary": "Filter books",
"operationId": "FilterBooks",
"parameters": [
+ {
+ "type": "string",
+ "name": "author",
+ "in": "query"
+ },
{
"type": "integer",
"name": "page",
@@ -170,6 +175,59 @@ const docTemplate = `{
}
}
},
+ "/books/search/": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "books"
+ ],
+ "summary": "Search books",
+ "operationId": "SearchBooks",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Search query",
+ "name": "search",
+ "in": "query",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/service.SearchBooksResult"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/handler.Error"
+ }
+ },
+ "422": {
+ "description": "Unprocessable Entity",
+ "schema": {
+ "$ref": "#/definitions/handler.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/handler.Error"
+ }
+ }
+ }
+ }
+ },
"/books/{id}/": {
"get": {
"security": [
@@ -2249,6 +2307,33 @@ const docTemplate = `{
"type": "string"
}
}
+ },
+ "service.SearchBookType": {
+ "type": "string",
+ "enum": [
+ "book",
+ "author",
+ "topic"
+ ],
+ "x-enum-varnames": [
+ "SearchBookTypeBook",
+ "SearchBookTypeAuthor",
+ "SearchBookTypeTopic"
+ ]
+ },
+ "service.SearchBooksResult": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "type": {
+ "$ref": "#/definitions/service.SearchBookType"
+ },
+ "url": {
+ "type": "string"
+ }
+ }
}
}
}`
diff --git a/server/docs/swagger.json b/server/docs/swagger.json
index 076f416..c894249 100644
--- a/server/docs/swagger.json
+++ b/server/docs/swagger.json
@@ -23,6 +23,11 @@
"summary": "Filter books",
"operationId": "FilterBooks",
"parameters": [
+ {
+ "type": "string",
+ "name": "author",
+ "in": "query"
+ },
{
"type": "integer",
"name": "page",
@@ -159,6 +164,59 @@
}
}
},
+ "/books/search/": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "books"
+ ],
+ "summary": "Search books",
+ "operationId": "SearchBooks",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Search query",
+ "name": "search",
+ "in": "query",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/service.SearchBooksResult"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/handler.Error"
+ }
+ },
+ "422": {
+ "description": "Unprocessable Entity",
+ "schema": {
+ "$ref": "#/definitions/handler.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/handler.Error"
+ }
+ }
+ }
+ }
+ },
"/books/{id}/": {
"get": {
"security": [
@@ -2238,6 +2296,33 @@
"type": "string"
}
}
+ },
+ "service.SearchBookType": {
+ "type": "string",
+ "enum": [
+ "book",
+ "author",
+ "topic"
+ ],
+ "x-enum-varnames": [
+ "SearchBookTypeBook",
+ "SearchBookTypeAuthor",
+ "SearchBookTypeTopic"
+ ]
+ },
+ "service.SearchBooksResult": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "type": {
+ "$ref": "#/definitions/service.SearchBookType"
+ },
+ "url": {
+ "type": "string"
+ }
+ }
}
}
}
\ No newline at end of file
diff --git a/server/docs/swagger.yaml b/server/docs/swagger.yaml
index 1e785e4..d6d7972 100644
--- a/server/docs/swagger.yaml
+++ b/server/docs/swagger.yaml
@@ -422,6 +422,25 @@ definitions:
revision_date:
type: string
type: object
+ service.SearchBookType:
+ enum:
+ - book
+ - author
+ - topic
+ type: string
+ x-enum-varnames:
+ - SearchBookTypeBook
+ - SearchBookTypeAuthor
+ - SearchBookTypeTopic
+ service.SearchBooksResult:
+ properties:
+ name:
+ type: string
+ type:
+ $ref: '#/definitions/service.SearchBookType'
+ url:
+ type: string
+ type: object
info:
contact: {}
paths:
@@ -431,6 +450,9 @@ paths:
- application/json
operationId: FilterBooks
parameters:
+ - in: query
+ name: author
+ type: string
- in: query
name: page
type: integer
@@ -745,6 +767,41 @@ paths:
summary: Reject new book
tags:
- books
+ /books/search/:
+ get:
+ operationId: SearchBooks
+ parameters:
+ - description: Search query
+ in: query
+ name: search
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ items:
+ items:
+ $ref: '#/definitions/service.SearchBooksResult'
+ type: array
+ type: array
+ "400":
+ description: Bad Request
+ schema:
+ $ref: '#/definitions/handler.Error'
+ "422":
+ description: Unprocessable Entity
+ schema:
+ $ref: '#/definitions/handler.Error'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/handler.Error'
+ summary: Search books
+ tags:
+ - books
/change-requests/:
get:
consumes:
diff --git a/server/endpoint/public_api_endpoints.go b/server/endpoint/public_api_endpoints.go
index 082dc01..a1b1b7d 100644
--- a/server/endpoint/public_api_endpoints.go
+++ b/server/endpoint/public_api_endpoints.go
@@ -15,6 +15,7 @@ func (r *Router) PublicAPIEndpoints() {
// books
r.Group("books").
GET("/", r.handler.FilterBooks).
+ GET("/search/", r.handler.SearchBooks).
Use(r.mw.RequestBook).
GET("{id}/", r.handler.GetBook)
diff --git a/server/handler/book_handler.go b/server/handler/book_handler.go
index 9b3ff85..08ab2e9 100644
--- a/server/handler/book_handler.go
+++ b/server/handler/book_handler.go
@@ -199,6 +199,7 @@ func (h *Handler) FilterBooks(w http.ResponseWriter, r *http.Request) {
books, count, err := h.service.FilterBooks(ctx, ds.BooksFilter{
EntitiesFilter: filter,
+ Author: req.Author,
})
if err != nil {
Abort(w, r, err)
@@ -211,6 +212,37 @@ func (h *Handler) FilterBooks(w http.ResponseWriter, r *http.Request) {
})
}
+// SearchBooks handles search across books, books authors and books topics.
+//
+// @ID SearchBooks
+// @Summary Search books
+// @Tags books
+// @Produce json
+// @Param search query string true "Search query"
+// @Success 200 {array} []service.SearchBooksResult
+// @Failure 400 {object} Error
+// @Failure 422 {object} Error
+// @Failure 500 {object} Error
+// @Router /books/search/ [get]
+func (h *Handler) SearchBooks(w http.ResponseWriter, r *http.Request) {
+ ctx, span := h.tracer.Start(r.Context(), "SearchBooks")
+ defer span.End()
+
+ query := r.URL.Query().Get("search")
+ if len(query) < 3 { //nolint:mnd
+ Abort(w, r, app.ErrBadRequest("search query is too short, at least 3 characters are needed for a proper performance in this show"))
+ return
+ }
+
+ result, err := h.service.SearchBooks(ctx, query)
+ if err != nil {
+ Abort(w, r, err)
+ return
+ }
+
+ jsonOK(w, result)
+}
+
// FilterBooksView renders the books listing page with filtering UI.
func (h *Handler) FilterBooksView(w http.ResponseWriter, r *http.Request) {
ctx, span := h.tracer.Start(r.Context(), "FilterBooksView")
diff --git a/server/handler/handler.go b/server/handler/handler.go
index 57bb20f..f408dc4 100644
--- a/server/handler/handler.go
+++ b/server/handler/handler.go
@@ -630,16 +630,16 @@ func GetSessionFromCookie(r *http.Request) string {
type ctxKey int
-const ctxServerJSON ctxKey = iota
+const ctxServeJSON ctxKey = iota
// SetServerJSON marks request context to indicate the response must be JSON.
func SetServerJSON(r *http.Request) *http.Request {
- return r.WithContext(context.WithValue(r.Context(), ctxServerJSON, true))
+ return r.WithContext(context.WithValue(r.Context(), ctxServeJSON, true))
}
// ShouldServeJSON reports whether the current request must be served as JSON.
func ShouldServeJSON(r *http.Request) bool {
- v, ok := r.Context().Value(ctxServerJSON).(bool)
+ v, ok := r.Context().Value(ctxServeJSON).(bool)
return ok && v
}
diff --git a/server/request/book_request.go b/server/request/book_request.go
index 21f15ee..7e611eb 100644
--- a/server/request/book_request.go
+++ b/server/request/book_request.go
@@ -75,6 +75,8 @@ type UpdateBook struct {
// FilterBooks defines filtering options specific to books.
type FilterBooks struct {
FilterEntities
+
+ Author string `json:"author" url:"author,omitempty"`
}
// RejectBook represents a request payload for rejecting a book.
diff --git a/test/api_test/api_test.go b/test/api_test/api_test.go
index f99fcd2..10d7953 100644
--- a/test/api_test/api_test.go
+++ b/test/api_test/api_test.go
@@ -415,7 +415,7 @@ func create[T any](t *testing.T, override ...T) *T {
type Query struct {
Path string
- Params any
+ Params any // struct only
}
func (q Query) String(t *testing.T) string {
diff --git a/test/api_test/book_test.go b/test/api_test/book_test.go
index 4d75e36..bc0b3e5 100644
--- a/test/api_test/book_test.go
+++ b/test/api_test/book_test.go
@@ -196,6 +196,41 @@ func TestFilterBooks(t *testing.T) {
GET(t, req, &resp)
assert.Len(t, resp.Data, 3)
})
+
+ t.Run("filter by topic", func(t *testing.T) {
+ topic := create(t, ds.Topic{Type: ds.EntityTypeBook})
+ book := create(t, ds.Book{
+ Entity: &ds.Entity{
+ Topics: []ds.Topic{*topic},
+ Status: ds.EntityStatusApproved,
+ Visibility: ds.EntityVisibilityPublic,
+ },
+ })
+ req.Params = request.FilterEntities{
+ Topics: []string{topic.PublicID},
+ }
+
+ GET(t, req, &resp)
+ assert.Len(t, resp.Data, 1)
+ assert.Equal(t, resp.Data[0].PublicID, book.PublicID)
+ })
+
+ t.Run("filter by author", func(t *testing.T) {
+ book := create(t, ds.Book{
+ Entity: &ds.Entity{
+ Status: ds.EntityStatusApproved,
+ Visibility: ds.EntityVisibilityPublic,
+ },
+ Authors: []ds.BookAuthor{{Name: random.String(32)}},
+ })
+ req.Params = request.FilterBooks{
+ Author: book.Authors[0].Name,
+ }
+
+ GET(t, req, &resp)
+ assert.Len(t, resp.Data, 1)
+ assert.Equal(t, resp.Data[0].PublicID, book.PublicID)
+ })
}
func TestUpdateBook_WithReview(t *testing.T) {
@@ -453,3 +488,86 @@ func TestDeleteBook(t *testing.T) {
"deleted_at": test.NotNull,
})
}
+
+func TestSearchBooks(t *testing.T) {
+ topic := create(t, ds.Topic{
+ Type: ds.EntityTypeBook,
+ Name: "Testing",
+ })
+
+ book := create(t, ds.Book{
+ Entity: &ds.Entity{
+ Title: "Learn Go With Tests",
+ Status: ds.EntityStatusApproved,
+ Visibility: ds.EntityVisibilityPublic,
+ Topics: []ds.Topic{*topic},
+ },
+ Authors: []ds.BookAuthor{{
+ Name: "John Tester",
+ Link: random.URL(),
+ }},
+ })
+
+ // unrelated book should not appear
+ create(t, ds.Book{
+ Entity: &ds.Entity{
+ Status: ds.EntityStatusApproved,
+ Visibility: ds.EntityVisibilityPublic,
+ },
+ })
+
+ type SearchQuery struct {
+ Search string `url:"search"`
+ }
+
+ req := Query{
+ Path: "books/search/",
+ Params: SearchQuery{Search: "test"},
+ }
+ var resp []service.SearchBooksResult
+ GET(t, req, &resp)
+
+ // book by title
+ assert.Contains(t, resp, service.SearchBooksResult{
+ Type: service.SearchBookTypeBook,
+ Name: book.Title,
+ URL: "/books/" + book.PublicID + "/",
+ })
+
+ // author
+ assert.Contains(t, resp, service.SearchBooksResult{
+ Type: service.SearchBookTypeAuthor,
+ Name: "John Tester",
+ URL: "/books/?author=John Tester",
+ })
+
+ // topic
+ assert.Contains(t, resp, service.SearchBooksResult{
+ Type: service.SearchBookTypeTopic,
+ Name: "Testing",
+ URL: "/books/?topics=" + topic.PublicID,
+ })
+
+ t.Run("no match returns empty result", func(t *testing.T) {
+ var resp []service.SearchBooksResult
+ GET(t, Query{Path: "books/search", Params: SearchQuery{Search: random.String()}}, &resp)
+ assert.Empty(t, resp)
+ })
+
+ t.Run("draft book not returned", func(t *testing.T) {
+ draftBook := create(t, ds.Book{
+ Entity: &ds.Entity{
+ Title: "Test Draft Book",
+ Status: ds.EntityStatusUnderReview,
+ Visibility: ds.EntityVisibilityPublic,
+ },
+ })
+
+ var resp []service.SearchBooksResult
+ GET(t, Query{Path: "books/search", Params: SearchQuery{Search: draftBook.Title}}, &resp)
+
+ for _, r := range resp {
+ assert.NotEqual(t, draftBook.Title, r.Name)
+ }
+ })
+}
diff --git a/test/factory/book.go b/test/factory/book.go
index ad29494..a149c67 100644
--- a/test/factory/book.go
+++ b/test/factory/book.go
@@ -59,6 +59,7 @@ func (f *Factory) CreateBook(overrideOpt ...ds.Book) (m *ds.Book, err error) {
return nil, err
}
m.Topics = []ds.Topic{*topic}
+
err = f.repo.AttachTopics(context.Background(), m.ID, m.Topics)
if err != nil {
return nil, err