package main import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "testing" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "bookhoard/internal/database" ) // HTTP helpers ----------------------------------------------------------- func smartCreateCollection(t *testing.T, setup *TestServerSetup, token, name string, collectionType string) string { t.Helper() payload := map[string]interface{}{ "name": name, "description": "smart collections test", "color": "blue", "icon": "📚", "collection_type": collectionType, } body, _ := json.Marshal(payload) req, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections", bytes.NewBuffer(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+token) resp, err := http.DefaultClient.Do(req) require.NoError(t, err) defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) require.Equal(t, http.StatusCreated, resp.StatusCode, "collection creation should succeed") var result map[string]interface{} require.NoError(t, json.NewDecoder(resp.Body).Decode(&result)) return result["id"].(string) } func smartGetCollection(t *testing.T, setup *TestServerSetup, token, collectionID string) map[string]interface{} { t.Helper() req, _ := http.NewRequest("GET", setup.Server.URL+"/api/collections/"+collectionID, nil) req.Header.Set("Authorization", "Bearer "+token) resp, err := http.DefaultClient.Do(req) require.NoError(t, err) defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) require.Equal(t, http.StatusOK, resp.StatusCode) var result map[string]interface{} require.NoError(t, json.NewDecoder(resp.Body).Decode(&result)) return result } func smartCreateLibraryAndBooks(t *testing.T, setup *TestServerSetup, userID uuid.UUID, titlesByGenre map[string]string) (libraryID string, bookIDs []string) { t.Helper() ctx := context.Background() libType, err := setup.DB.GetLibraryTypeByName(ctx, "ebooks") require.NoError(t, err) uniqueName := fmt.Sprintf("Test Smart Rules Library %d", time.Now().UnixNano()) library, err := setup.DB.CreateLibrary(ctx, database.CreateLibraryParams{ Name: uniqueName, Description: pgtype.Text{String: "smart collections test library", Valid: true}, LibraryTypeID: libType.ID, CreatedByAdminID: pgtype.UUID{Bytes: [16]byte(userID), Valid: true}, }) require.NoError(t, err, "should create test library") libUUID, err := uuid.FromBytes(library.ID.Bytes[0:16]) require.NoError(t, err) t.Cleanup(func() { _ = setup.DB.DeleteLibrary(ctx, library.ID) }) for title, genre := range titlesByGenre { media, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{ LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true}, Title: title, Authors: []string{"Smart Rule Author"}, Genre: pgtype.Text{String: genre, Valid: true}, FilePath: fmt.Sprintf("/tmp/%s-%d.epub", title, time.Now().UnixNano()), FileSize: pgtype.Int8{Int64: 1024, Valid: true}, MimeType: pgtype.Text{String: "application/epub+zip", Valid: true}, }) require.NoError(t, err, "should create test media item") mediaUUID, err := uuid.FromBytes(media.ID.Bytes[0:16]) require.NoError(t, err) bookIDs = append(bookIDs, mediaUUID.String()) } return libUUID.String(), bookIDs } func smartLibraryTitleSet(t *testing.T, coll map[string]interface{}) map[string]bool { t.Helper() books, _ := coll["books"].([]interface{}) set := make(map[string]bool, len(books)) for _, b := range books { if m, ok := b.(map[string]interface{}); ok { if title, ok := m["title"].(string); ok { set[title] = true } } } return set } // Tests ------------------------------------------------------------------ func TestSmartCollections(t *testing.T) { setup := setupTestServer(t) adminID := getTestUserID(t, setup.DB) t.Run("TreeCollectionLiveMembership", func(t *testing.T) { // The unified engine (item 3.2): a collection created WITH a // books-scope tree evaluates LIVE — stored links are ignored, // only tree matches render. _, bookIDs := smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{ "Smart SF Book One": "Science Fiction", "Smart SF Book Two": "Science Fiction", "Smart FANT Book": "Fantasy", }) _ = bookIDs tree := map[string]interface{}{ "scope": "books", "mode": "all", "children": []map[string]interface{}{ {"field": "genre", "op": "is", "values": []string{"Science Fiction"}}, }, } body, _ := json.Marshal(map[string]interface{}{ "name": "Test Smart Tree " + uuid.NewString()[:8], "filter_tree": tree, }) req, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections", bytes.NewBuffer(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+setup.Token) resp, err := http.DefaultClient.Do(req) require.NoError(t, err) require.Equal(t, http.StatusCreated, resp.StatusCode) var created struct { ID string `json:"id"` } require.NoError(t, json.NewDecoder(resp.Body).Decode(&created)) _ = resp.Body.Close() t.Cleanup(func() { del, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/collections/"+created.ID, nil) del.Header.Set("Authorization", "Bearer "+setup.Token) _, _ = http.DefaultClient.Do(del) }) detail := smartGetCollection(t, setup, setup.Token, created.ID) assert.Equal(t, true, detail["is_smart"], "tree presence derives the type") memberTitles := smartLibraryTitleSet(t, detail) assert.True(t, memberTitles["Smart SF Book One"]) assert.True(t, memberTitles["Smart SF Book Two"]) assert.False(t, memberTitles["Smart FANT Book"], "the tree's AND governs membership live") }) t.Run("SmartCollectionsRejectMembershipMutations", func(t *testing.T) { smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{ "Smart Guard Book": "Science Fiction", }) tree := map[string]interface{}{ "scope": "books", "mode": "all", "children": []map[string]interface{}{ {"field": "genre", "op": "is", "values": []string{"Science Fiction"}}, }, } body, _ := json.Marshal(map[string]interface{}{ "name": "Test Smart Guard " + uuid.NewString()[:8], "filter_tree": tree, }) req, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections", bytes.NewBuffer(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+setup.Token) resp, err := http.DefaultClient.Do(req) require.NoError(t, err) require.Equal(t, http.StatusCreated, resp.StatusCode) var created struct { ID string `json:"id"` } require.NoError(t, json.NewDecoder(resp.Body).Decode(&created)) _ = resp.Body.Close() collID := created.ID t.Cleanup(func() { del, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/collections/"+collID, nil) del.Header.Set("Authorization", "Bearer "+setup.Token) _, _ = http.DefaultClient.Do(del) }) addBody, _ := json.Marshal(map[string]interface{}{"book_ids": []string{uuid.NewString()}}) addReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections/"+collID+"/books", bytes.NewBuffer(addBody)) addReq.Header.Set("Content-Type", "application/json") addReq.Header.Set("Authorization", "Bearer "+setup.Token) addResp, err := http.DefaultClient.Do(addReq) require.NoError(t, err) require.Equal(t, http.StatusBadRequest, addResp.StatusCode) _ = addResp.Body.Close() removeReq, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/collections/"+collID+"/books/"+uuid.NewString(), nil) removeReq.Header.Set("Authorization", "Bearer "+setup.Token) removeResp, err := http.DefaultClient.Do(removeReq) require.NoError(t, err) require.Equal(t, http.StatusBadRequest, removeResp.StatusCode) _ = removeResp.Body.Close() }) t.Run("SystemCollectionsBlockedFromTheEditor", func(t *testing.T) { // System collections may never carry rules/trees — the editor // route refuses them (item 3.4: the page IS the editor). colls, err := setup.DB.GetCollectionsByUser(ownerSeedCtx(pgtype.UUID{Bytes: [16]byte(adminID), Valid: true}), pgtype.UUID{Bytes: [16]byte(adminID), Valid: true}) require.NoError(t, err) var systemID string for _, c := range colls { if c.IsSystemCollection.Bool { systemID = uuid.UUID(c.ID.Bytes).String() break } } require.NotEmpty(t, systemID, "admin user should have system collections") req, _ := http.NewRequest("GET", setup.Server.URL+"/collections/"+systemID+"/rules", nil) req.Header.Set("Authorization", "Bearer "+setup.Token) resp, err := http.DefaultClient.Do(req) require.NoError(t, err) defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) assert.NotEqual(t, http.StatusOK, resp.StatusCode, "system collections never render the smart editor") }) t.Run("CollectionsListRespectsIncludeAutoWithLiveCounts", func(t *testing.T) { _, _ = smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{ "Smart List Book": "Science Fiction", }) tree := map[string]interface{}{ "scope": "books", "mode": "all", "children": []map[string]interface{}{ {"field": "genre", "op": "is", "values": []string{"Science Fiction"}}, }, } body, _ := json.Marshal(map[string]interface{}{ "name": "Test Smart List " + uuid.NewString()[:8], "filter_tree": tree, }) req2, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections", bytes.NewBuffer(body)) req2.Header.Set("Content-Type", "application/json") req2.Header.Set("Authorization", "Bearer "+setup.Token) resp2, err := http.DefaultClient.Do(req2) require.NoError(t, err) require.Equal(t, http.StatusCreated, resp2.StatusCode) var created struct { ID string `json:"id"` } require.NoError(t, json.NewDecoder(resp2.Body).Decode(&created)) _ = resp2.Body.Close() collID := created.ID t.Cleanup(func() { del, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/collections/"+collID, nil) del.Header.Set("Authorization", "Bearer "+setup.Token) _, _ = http.DefaultClient.Do(del) }) // Default list (the Android app's read): smart collections hidden. req, _ := http.NewRequest("GET", setup.Server.URL+"/api/collections", nil) req.Header.Set("Authorization", "Bearer "+setup.Token) defaultResp, err := http.DefaultClient.Do(req) require.NoError(t, err) defer func(Body io.ReadCloser) { _ = Body.Close() }(defaultResp.Body) var defaultData struct { Collections []struct { ID string `json:"id"` } `json:"collections"` } require.NoError(t, json.NewDecoder(defaultResp.Body).Decode(&defaultData)) for _, c := range defaultData.Collections { assert.NotEqual(t, collID, c.ID, "default listing excludes rule-driven collections (app compat)") } // Opt-in listing includes them with LIVE counts. scopeReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/collections?include_auto=true", nil) scopeReq.Header.Set("Authorization", "Bearer "+setup.Token) scopeResp, err := http.DefaultClient.Do(scopeReq) require.NoError(t, err) defer func(Body io.ReadCloser) { _ = Body.Close() }(scopeResp.Body) var scopeData struct { Collections []struct { ID string `json:"id"` BookCount int `json:"book_count"` IsSmart bool `json:"is_smart"` } `json:"collections"` } require.NoError(t, json.NewDecoder(scopeResp.Body).Decode(&scopeData)) found := false for _, c := range scopeData.Collections { if c.ID == collID { found = true assert.True(t, c.IsSmart) assert.Equal(t, 1, c.BookCount, "smart card counts rule matches live") } } assert.True(t, found, "include_auto=true must surface smart collections") }) }