From 182b33ae79eb18d9a65bab19cf4e3b8632a102c1 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sun, 21 Jan 2024 22:03:57 -0500 Subject: [PATCH] Add Category tests, implement for SQLite --- .../SQLite/SQLiteCategoryData.fs | 14 +- src/MyWebLog.Tests/Data/CategoryDataTests.fs | 135 +++++++++++++++++- src/MyWebLog.Tests/Data/SQLiteDataTests.fs | 116 +++++++++++++-- src/MyWebLog/Maintenance.fs | 27 ++-- 4 files changed, 266 insertions(+), 26 deletions(-) diff --git a/src/MyWebLog.Data/SQLite/SQLiteCategoryData.fs b/src/MyWebLog.Data/SQLite/SQLiteCategoryData.fs index e1fb304..66d325f 100644 --- a/src/MyWebLog.Data/SQLite/SQLiteCategoryData.fs +++ b/src/MyWebLog.Data/SQLite/SQLiteCategoryData.fs @@ -80,13 +80,21 @@ type SQLiteCategoryData(conn: SqliteConnection, ser: JsonSerializer, log: ILogge | Some cat -> // Reassign any children to the category's parent category let! children = conn.countByField Table.Category parentIdField EQ (string catId) - if children > 0 then - do! conn.patchByField Table.Category parentIdField EQ (string catId) {| ParentId = cat.ParentId |} + if children > 0L then + match cat.ParentId with + | Some _ -> + do! conn.patchByField Table.Category parentIdField EQ (string catId) {| ParentId = cat.ParentId |} + | None -> + do! conn.customNonQuery + $"""UPDATE {Table.Category} + SET data = json_remove(data, '$.ParentId') + WHERE {Query.whereByField parentIdField EQ "@field"}""" + [ fieldParam (string catId) ] // Delete the category off all posts where it is assigned, and the category itself let catIdField = nameof Post.Empty.CategoryIds let! posts = conn.customList - $"SELECT data ->> '{Post.Empty.Id}', data -> '{catIdField}' + $"SELECT data ->> '{nameof Post.Empty.Id}', data -> '{catIdField}' FROM {Table.Post} WHERE {Document.Query.whereByWebLog} AND EXISTS diff --git a/src/MyWebLog.Tests/Data/CategoryDataTests.fs b/src/MyWebLog.Tests/Data/CategoryDataTests.fs index 01872a1..a148b7e 100644 --- a/src/MyWebLog.Tests/Data/CategoryDataTests.fs +++ b/src/MyWebLog.Tests/Data/CategoryDataTests.fs @@ -4,8 +4,14 @@ open Expecto open MyWebLog open MyWebLog.Data +/// The ID of the root web log +let rootId = WebLogId "uSitJEuD3UyzWC9jgOHc8g" + +/// The ID of the Favorites category +let favoritesId = CategoryId "S5JflPsJ9EG7gA2LD4m92A" + /// Tests for the Add method -let addTests (data: IData) = task { +let ``Add succeeds`` (data: IData) = task { let category = { Category.Empty with Id = CategoryId "added-cat"; WebLogId = WebLogId "test"; Name = "Added"; Slug = "added" } do! data.Category.Add category @@ -13,3 +19,130 @@ let addTests (data: IData) = task { Expect.isSome stored "The category should have been added" } +let ``CountAll succeeds when categories exist`` (data: IData) = task { + let! count = data.Category.CountAll rootId + Expect.equal count 3 "There should have been 3 categories" +} + +let ``CountAll succeeds when categories do not exist`` (data: IData) = task { + let! count = data.Category.CountAll WebLogId.Empty + Expect.equal count 0 "There should have been no categories" +} + +let ``CountTopLevel succeeds when top-level categories exist`` (data: IData) = task { + let! count = data.Category.CountTopLevel rootId + Expect.equal count 2 "There should have been 2 top-level categories" +} + +let ``CountTopLevel succeeds when no top-level categories exist`` (data: IData) = task { + let! count = data.Category.CountTopLevel WebLogId.Empty + Expect.equal count 0 "There should have been no top-level categories" +} + +let ``FindAllForView succeeds`` (data: IData) = task { + let! all = data.Category.FindAllForView rootId + Expect.equal all.Length 3 "There should have been 3 categories returned" + Expect.equal all[0].Name "Favorites" "The first category is incorrect" + Expect.equal all[0].PostCount 1 "There should be one post in this category" + Expect.equal all[1].Name "Spitball" "The second category is incorrect" + Expect.equal all[1].PostCount 2 "There should be two posts in this category" + Expect.equal all[2].Name "Moonshot" "The third category is incorrect" + Expect.equal all[2].PostCount 1 "There should be one post in this category" +} + +let ``FindById succeeds when a category is found`` (data: IData) = task { + let! cat = data.Category.FindById favoritesId rootId + Expect.isSome cat "There should have been a category returned" + Expect.equal cat.Value.Name "Favorites" "The category retrieved is incorrect" + Expect.equal cat.Value.Slug "favorites" "The slug is incorrect" + Expect.equal cat.Value.Description (Some "Favorite posts") "The description is incorrect" + Expect.isNone cat.Value.ParentId "There should have been no parent ID" +} + +let ``FindById succeeds when a category is not found`` (data: IData) = task { + let! cat = data.Category.FindById CategoryId.Empty rootId + Expect.isNone cat "There should not have been a category returned" +} + +let ``FindByWebLog succeeds when categories exist`` (data: IData) = task { + let! cats = data.Category.FindByWebLog rootId + Expect.equal cats.Length 3 "There should be 3 categories" + Expect.exists cats (fun it -> it.Name = "Favorites") "Favorites category not found" + Expect.exists cats (fun it -> it.Name = "Spitball") "Spitball category not found" + Expect.exists cats (fun it -> it.Name = "Moonshot") "Moonshot category not found" +} + +let ``FindByWebLog succeeds when no categories exist`` (data: IData) = task { + let! cats = data.Category.FindByWebLog WebLogId.Empty + Expect.isEmpty cats "There should have been no categories returned" +} + +let ``Update succeeds`` (data: IData) = task { + match! data.Category.FindById favoritesId rootId with + | Some cat -> + do! data.Category.Update { cat with Name = "My Favorites"; Slug = "my-favorites"; Description = None } + match! data.Category.FindById favoritesId rootId with + | Some updated -> + Expect.equal updated.Name "My Favorites" "Name not updated properly" + Expect.equal updated.Slug "my-favorites" "Slug not updated properly" + Expect.isNone updated.Description "Description should have been removed" + | None -> Expect.isTrue false "The updated favorites category could not be retrieved" + | None -> Expect.isTrue false "The favorites category could not be retrieved" +} + +let ``Delete succeeds when the category is deleted (no posts)`` (data: IData) = task { + let! result = data.Category.Delete (CategoryId "added-cat") (WebLogId "test") + Expect.equal result CategoryDeleted "The category should have been deleted" + let! cat = data.Category.FindById (CategoryId "added-cat") (WebLogId "test") + Expect.isNone cat "The deleted category should not still exist" +} + +let ``Delete succeeds when the category does not exist`` (data: IData) = task { + let! result = data.Category.Delete CategoryId.Empty (WebLogId "none") + Expect.equal result CategoryNotFound "The category should not have been found" +} + +let ``Delete succeeds when reassigning parent category to None`` (data: IData) = task { + let moonshotId = CategoryId "ScVpyu1e7UiP7bDdge3ZEw" + let spitballId = CategoryId "jw6N69YtTEWVHAO33jHU-w" + let! result = data.Category.Delete spitballId rootId + Expect.equal result ReassignedChildCategories "Child categories should have been reassigned" + match! data.Category.FindById moonshotId rootId with + | Some cat -> Expect.isNone cat.ParentId "Parent ID should have been cleared" + | None -> Expect.isTrue false "Unable to find former child category" +} + +let ``Delete succeeds when reassigning parent category to Some`` (data: IData) = task { + do! data.Category.Add { Category.Empty with Id = CategoryId "a"; WebLogId = WebLogId "test"; Name = "A" } + do! data.Category.Add + { Category.Empty with + Id = CategoryId "b" + WebLogId = WebLogId "test" + Name = "B" + ParentId = Some (CategoryId "a") } + do! data.Category.Add + { Category.Empty with + Id = CategoryId "c" + WebLogId = WebLogId "test" + Name = "C" + ParentId = Some (CategoryId "b") } + let! result = data.Category.Delete (CategoryId "b") (WebLogId "test") + Expect.equal result ReassignedChildCategories "Child categories should have been reassigned" + match! data.Category.FindById (CategoryId "c") (WebLogId "test") with + | Some cat -> Expect.equal cat.ParentId (Some (CategoryId "a")) "Parent category ID not reassigned properly" + | None -> Expect.isTrue false "Expected former child category not found" +} + +let ``Delete succeeds and removes category from posts`` (data: IData) = task { + let moonshotId = CategoryId "ScVpyu1e7UiP7bDdge3ZEw" + let postId = PostId "RCsCU2puYEmkpzotoi8p4g" + match! data.Post.FindById postId rootId with + | Some post -> + Expect.equal post.CategoryIds [ moonshotId ] "Post category IDs are not as expected" + let! result = data.Category.Delete moonshotId rootId + Expect.equal result CategoryDeleted "The category should have been deleted (no children)" + match! data.Post.FindById postId rootId with + | Some p -> Expect.isEmpty p.CategoryIds "Category ID was not removed" + | None -> Expect.isTrue false "The expected updated post was not found" + | None -> Expect.isTrue false "The expected test post was not found" +} diff --git a/src/MyWebLog.Tests/Data/SQLiteDataTests.fs b/src/MyWebLog.Tests/Data/SQLiteDataTests.fs index 073115e..f010601 100644 --- a/src/MyWebLog.Tests/Data/SQLiteDataTests.fs +++ b/src/MyWebLog.Tests/Data/SQLiteDataTests.fs @@ -1,5 +1,6 @@ module SQLiteDataTests +open System.IO open BitBadger.Documents open Expecto open Microsoft.Extensions.Logging.Abstractions @@ -11,9 +12,12 @@ open Newtonsoft.Json /// JSON serializer let ser = Json.configure (JsonSerializer.CreateDefault()) +/// The test database name +let dbName = "test-db.db" + /// Create a SQLiteData instance for testing let mkData () = - Sqlite.Configuration.useConnectionString "Data Source=./test-db.db" + Sqlite.Configuration.useConnectionString $"Data Source=./{dbName}" let conn = Sqlite.Configuration.dbConn () SQLiteData(conn, NullLogger(), ser) :> IData @@ -21,13 +25,24 @@ let mkData () = let dispose (data: IData) = (data :?> SQLiteData).Conn.Dispose() +/// Create a fresh environment from the root backup +let freshEnvironment (data: IData option) = task { + let env = + match data with + | Some d -> d + | None -> + File.Delete dbName + mkData () + do! env.StartUp() + // This exercises Restore for all implementations; all tests are dependent on it working as expected + do! Maintenance.Backup.restoreBackup "root-weblog.json" None false false env +} + /// Set up the environment for the SQLite tests let environmentSetUp = testList "Environment" [ testTask "creating database" { let data = mkData () - try - do! data.StartUp() - do! Maintenance.Backup.restoreBackup "root-weblog.json" None false data + try do! freshEnvironment (Some data) finally dispose data } ] @@ -36,18 +51,101 @@ let environmentSetUp = testList "Environment" [ let categoryTests = testList "Category" [ testTask "Add succeeds" { let data = mkData () - try do! CategoryDataTests.addTests data + try do! CategoryDataTests.``Add succeeds`` data finally dispose data } + testList "CountAll" [ + testTask "succeeds when categories exist" { + let data = mkData () + try do! CategoryDataTests.``CountAll succeeds when categories exist`` data + finally dispose data + } + testTask "succeeds when categories do not exist" { + let data = mkData () + try do! CategoryDataTests.``CountAll succeeds when categories do not exist`` data + finally dispose data + } + ] + testList "CountTopLevel" [ + testTask "succeeds when top-level categories exist" { + let data = mkData () + try do! CategoryDataTests.``CountTopLevel succeeds when top-level categories exist`` data + finally dispose data + } + testTask "succeeds when no top-level categories exist" { + let data = mkData () + try do! CategoryDataTests.``CountTopLevel succeeds when no top-level categories exist`` data + finally dispose data + } + ] + testTask "FindAllForView succeeds" { + let data = mkData () + try do! CategoryDataTests.``FindAllForView succeeds`` data + finally dispose data + } + testList "FindById" [ + testTask "succeeds when a category is found" { + let data = mkData () + try do! CategoryDataTests.``FindById succeeds when a category is found`` data + finally dispose data + } + testTask "succeeds when a category is not found" { + let data = mkData () + try do! CategoryDataTests.``FindById succeeds when a category is not found`` data + finally dispose data + } + ] + testList "FindByWebLog" [ + testTask "succeeds when categories exist" { + let data = mkData () + try do! CategoryDataTests.``FindByWebLog succeeds when categories exist`` data + finally dispose data + } + testTask "succeeds when no categories exist" { + let data = mkData () + try do! CategoryDataTests.``FindByWebLog succeeds when no categories exist`` data + finally dispose data + } + ] + testTask "Update succeeds" { + let data = mkData () + try do! CategoryDataTests.``Update succeeds`` data + finally dispose data + } + testList "Delete" [ + testTask "succeeds when the category is deleted (no posts)" { + let data = mkData () + try do! CategoryDataTests.``Delete succeeds when the category is deleted (no posts)`` data + finally dispose data + } + testTask "succeeds when the category does not exist" { + let data = mkData () + try do! CategoryDataTests.``Delete succeeds when the category does not exist`` data + finally dispose data + } + testTask "succeeds when reassigning parent category to None" { + let data = mkData () + try do! CategoryDataTests.``Delete succeeds when reassigning parent category to None`` data + finally dispose data + } + testTask "succeeds when reassigning parent category to Some" { + let data = mkData () + try do! CategoryDataTests.``Delete succeeds when reassigning parent category to Some`` data + finally dispose data + } + testTask "succeeds and removes category from posts" { + let data = mkData () + try do! CategoryDataTests.``Delete succeeds and removes category from posts`` data + finally dispose data + } + ] ] -open System.IO - /// Delete the SQLite database let environmentCleanUp = test "Clean Up" { - File.Delete "test-db.db" - Expect.isFalse (File.Exists "test-db.db") "The test SQLite database should have been deleted" + File.Delete dbName + Expect.isFalse (File.Exists dbName) "The test SQLite database should have been deleted" } /// All SQLite data tests diff --git a/src/MyWebLog/Maintenance.fs b/src/MyWebLog/Maintenance.fs index 1612add..43caaca 100644 --- a/src/MyWebLog/Maintenance.fs +++ b/src/MyWebLog/Maintenance.fs @@ -313,7 +313,7 @@ module Backup = displayStats $"{fileName} (for <>NAME<>) contains:" webLog archive } - let private doRestore archive newUrlBase (data: IData) = task { + let private doRestore archive newUrlBase isInteractive (data: IData) = task { let! restore = task { match! data.WebLog.FindById archive.WebLog.Id with | Some webLog when defaultArg newUrlBase webLog.UrlBase = webLog.UrlBase -> @@ -357,45 +357,46 @@ module Backup = } // Restore theme and assets (one at a time, as assets can be large) - printfn "" - printfn "- Importing theme..." + if isInteractive then + printfn "" + printfn "- Importing theme..." do! data.Theme.Save restore.Theme restore.Assets |> List.iter (EncodedAsset.toAsset >> data.ThemeAsset.Save >> Async.AwaitTask >> Async.RunSynchronously) // Restore web log data - printfn "- Restoring web log..." + if isInteractive then printfn "- Restoring web log..." // v2.0 backups will not have redirect rules; fix that if restoring to v2.1 or later let webLog = if isNull (box restore.WebLog.RedirectRules) then { restore.WebLog with RedirectRules = [] } else restore.WebLog do! data.WebLog.Add webLog - printfn "- Restoring users..." + if isInteractive then printfn "- Restoring users..." do! data.WebLogUser.Restore restore.Users - printfn "- Restoring categories and tag mappings..." + if isInteractive then printfn "- Restoring categories and tag mappings..." if not (List.isEmpty restore.TagMappings) then do! data.TagMap.Restore restore.TagMappings if not (List.isEmpty restore.Categories) then do! data.Category.Restore restore.Categories - printfn "- Restoring pages..." + if isInteractive then printfn "- Restoring pages..." if not (List.isEmpty restore.Pages) then do! data.Page.Restore restore.Pages - printfn "- Restoring posts..." + if isInteractive then printfn "- Restoring posts..." if not (List.isEmpty restore.Posts) then do! data.Post.Restore restore.Posts // TODO: comments not yet implemented - printfn "- Restoring uploads..." + if isInteractive then printfn "- Restoring uploads..." if not (List.isEmpty restore.Uploads) then do! data.Upload.Restore (restore.Uploads |> List.map EncodedUpload.toUpload) - displayStats "Restored for <>NAME<>:" restore.WebLog restore + if isInteractive then displayStats "Restored for <>NAME<>:" restore.WebLog restore } /// Decide whether to restore a backup - let internal restoreBackup fileName newUrlBase promptForOverwrite data = task { + let internal restoreBackup fileName newUrlBase promptForOverwrite isInteractive data = task { let serializer = getSerializer false use stream = new FileStream(fileName, FileMode.Open) @@ -413,7 +414,7 @@ module Backup = doOverwrite <- not (Console.ReadKey().Key = ConsoleKey.N) if doOverwrite then - do! doRestore archive newUrlBase data + do! doRestore archive newUrlBase isInteractive data else printfn $"{archive.WebLog.Name} backup restoration canceled" } @@ -445,7 +446,7 @@ module Backup = if args.Length = 2 || args.Length = 3 then let data = sp.GetRequiredService() let newUrlBase = if args.Length = 3 then Some args[2] else None - do! restoreBackup args[1] newUrlBase (args[0] <> "do-restore") data + do! restoreBackup args[1] newUrlBase (args[0] <> "do-restore") true data else eprintfn "Usage: myWebLog restore [backup-file-name] [*url-base]" eprintfn " * optional - will restore to original URL base if omitted"