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 smartPostRule(t *testing.T, setup *TestServerSetup, token, collectionID, field, operator, value string, enabled *bool) *http.Response { t.Helper() payload := map[string]interface{}{ "field": field, "operator": operator, "value": value, } if enabled != nil { payload["enabled"] = *enabled } body, _ := json.Marshal(payload) req, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections/"+collectionID+"/rules", 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) return resp } func smartDecodeRuleID(t *testing.T, resp *http.Response) string { t.Helper() defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) var result map[string]interface{} require.NoError(t, json.NewDecoder(resp.Body).Decode(&result)) rule := result["rule"].(map[string]interface{}) return rule["id"].(string) } // Data helpers ----------------------------------------------------------- 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(ctx, database.CreateMediaItemParams{ LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true}, Title: title, Author: pgtype.Text{String: "Smart Rule Author", Valid: true}, 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("FirstRuleConvertsCollectionToSmart_LiveMembership", func(t *testing.T) { _, bookIDs := smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{ "Smart SF Book One": "Science Fiction", "Smart SF Book Two": "Science Fiction", "Smart FANT Book": "Fantasy", }) // Rule evaluation ignores stored links: a static collection's own // added item must NOT show up once the collection is smart. collID := smartCreateCollection(t, setup, setup.Token, "Test Smart Live "+uuid.NewString()[:8], "static") // Add one hand-picked book (a Fantasy one that the smart rule // will not match — so it must vanish from the render). addBody, _ := json.Marshal(map[string]interface{}{"book_ids": []string{bookIDs[2]}}) 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.StatusNoContent, addResp.StatusCode) _ = addResp.Body.Close() staticDetail := smartGetCollection(t, setup, setup.Token, collID) assert.Equal(t, false, staticDetail["is_smart"]) assert.Equal(t, float64(1), staticDetail["book_count"], "static collection shows its stored link") resp := smartPostRule(t, setup, setup.Token, collID, "genre", "equals", "Science Fiction", nil) require.Equal(t, http.StatusCreated, resp.StatusCode) _ = resp.Body.Close() smartDetail := smartGetCollection(t, setup, setup.Token, collID) assert.Equal(t, true, smartDetail["is_smart"], "rules presence derives the type") assert.Equal(t, float64(2), smartDetail["book_count"], "live rule matches only") memberTitles := smartLibraryTitleSet(t, smartDetail) assert.True(t, memberTitles["Smart SF Book One"]) assert.True(t, memberTitles["Smart SF Book Two"]) assert.False(t, memberTitles["Smart FANT Book"], "hand-picked link is ignored under smart evaluation (v1 no hybrid)") }) t.Run("DisabledRuleExcludedFromLiveSet", func(t *testing.T) { smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{ "Smart Diss Book": "Science Fiction", }) collID := smartCreateCollection(t, setup, setup.Token, "Test Smart Disabled "+uuid.NewString()[:8], "smart") resp := smartPostRule(t, setup, setup.Token, collID, "genre", "equals", "Science Fiction", nil) require.Equal(t, http.StatusCreated, resp.StatusCode) ruleID := smartDecodeRuleID(t, resp) assert.Equal(t, float64(1), smartGetCollection(t, setup, setup.Token, collID)["book_count"]) // Disable: membership drops to zero at once. falseFlag := false dBody, _ := json.Marshal(map[string]interface{}{"enabled": falseFlag}) dReq, _ := http.NewRequest("PUT", setup.Server.URL+"/api/collections/"+collID+"/rules/"+ruleID, bytes.NewBuffer(dBody)) dReq.Header.Set("Content-Type", "application/json") dReq.Header.Set("Authorization", "Bearer "+setup.Token) dResp, err := http.DefaultClient.Do(dReq) require.NoError(t, err) require.Equal(t, http.StatusOK, dResp.StatusCode) _ = dResp.Body.Close() assert.Equal(t, float64(0), smartGetCollection(t, setup, setup.Token, collID)["book_count"], "disabled rules never match") }) t.Run("SmartCollectionsRejectMembershipMutations", func(t *testing.T) { smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{ "Smart Guard Book": "Science Fiction", }) collID := smartCreateCollection(t, setup, setup.Token, "Test Smart Guard "+uuid.NewString()[:8], "smart") resp := smartPostRule(t, setup, setup.Token, collID, "genre", "equals", "Science Fiction", nil) require.Equal(t, http.StatusCreated, resp.StatusCode) _ = resp.Body.Close() 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("RemoveLastRuleRevertsToStatic", func(t *testing.T) { smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{ "Smart Back Book": "Science Fiction", }) collID := smartCreateCollection(t, setup, setup.Token, "Test Smart Revert "+uuid.NewString()[:8], "smart") resp := smartPostRule(t, setup, setup.Token, collID, "tags", "contains", "no-such-tag", nil) require.Equal(t, http.StatusCreated, resp.StatusCode) ruleID := smartDecodeRuleID(t, resp) smartDetail := smartGetCollection(t, setup, setup.Token, collID) assert.Equal(t, true, smartDetail["is_smart"]) delReq, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/collections/"+collID+"/rules/"+ruleID, nil) delReq.Header.Set("Authorization", "Bearer "+setup.Token) delResp, err := http.DefaultClient.Do(delReq) require.NoError(t, err) require.Equal(t, http.StatusOK, delResp.StatusCode) _ = delResp.Body.Close() staticDetail := smartGetCollection(t, setup, setup.Token, collID) assert.Equal(t, false, staticDetail["is_smart"], "removing the last rule reverts to static") // Membership mutations work again on the reverted static. 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) assert.Equal(t, http.StatusNoContent, addResp.StatusCode) _ = addResp.Body.Close() }) t.Run("RuleValidationAndSystemCollections", func(t *testing.T) { collID := smartCreateCollection(t, setup, setup.Token, "Test Smart Valid "+uuid.NewString()[:8], "static") resp := smartPostRule(t, setup, setup.Token, collID, "not_a_field", "equals", "x", nil) require.Equal(t, http.StatusBadRequest, resp.StatusCode) _ = resp.Body.Close() resp = smartPostRule(t, setup, setup.Token, collID, "genre", "equals", "Science Fiction", nil) require.Equal(t, http.StatusCreated, resp.StatusCode) _ = resp.Body.Close() // System collections may never carry rules. colls, err := setup.DB.GetCollectionsByUser(context.Background(), 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") sysResp := smartPostRule(t, setup, setup.Token, systemID, "genre", "equals", "x", nil) require.Equal(t, http.StatusBadRequest, sysResp.StatusCode) _ = sysResp.Body.Close() }) t.Run("CollectionsListRespectsIncludeAutoWithLiveCounts", func(t *testing.T) { _, _ = smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{ "Smart List Book": "Science Fiction", }) collID := smartCreateCollection(t, setup, setup.Token, "Test Smart List "+uuid.NewString()[:8], "smart") resp := smartPostRule(t, setup, setup.Token, collID, "genre", "equals", "Science Fiction", nil) require.Equal(t, http.StatusCreated, resp.StatusCode) _ = resp.Body.Close() // 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") }) }