From 3a975d7f2880997ca12b45727f10a65cfd6c976a Mon Sep 17 00:00:00 2001 From: Patrick Stevens Date: Sun, 6 Aug 2023 21:23:21 +0100 Subject: [PATCH] Add tests (#60) --- Gitea.Declarative.Lib/Exception.fs | 10 + .../Gitea.Declarative.Lib.fsproj | 1 + .../Gitea.Declarative.Test.fsproj | 3 + Gitea.Declarative.Test/Logging.fs | 34 +++ Gitea.Declarative.Test/Result.fs | 14 ++ Gitea.Declarative.Test/TestRepo.fs | 234 ++++++++++++++++++ Gitea.Declarative.Test/Utils.fs | 99 ++++++++ Gitea.InMemory/Client.fs | 11 +- Gitea.InMemory/Domain.fs | 30 +-- Gitea.InMemory/Gitea.InMemory.fsproj | 1 + Gitea.InMemory/Server.fs | 18 ++ 11 files changed, 428 insertions(+), 27 deletions(-) create mode 100644 Gitea.Declarative.Lib/Exception.fs create mode 100644 Gitea.Declarative.Test/Logging.fs create mode 100644 Gitea.Declarative.Test/Result.fs create mode 100644 Gitea.Declarative.Test/TestRepo.fs create mode 100644 Gitea.InMemory/Server.fs diff --git a/Gitea.Declarative.Lib/Exception.fs b/Gitea.Declarative.Lib/Exception.fs new file mode 100644 index 0000000..84c3b04 --- /dev/null +++ b/Gitea.Declarative.Lib/Exception.fs @@ -0,0 +1,10 @@ +namespace Gitea.Declarative + +open System.Runtime.ExceptionServices + +[] +module internal Exception = + let reraiseWithOriginalStackTrace<'a> (e : exn) : 'a = + let edi = ExceptionDispatchInfo.Capture e + edi.Throw () + failwith "unreachable" diff --git a/Gitea.Declarative.Lib/Gitea.Declarative.Lib.fsproj b/Gitea.Declarative.Lib/Gitea.Declarative.Lib.fsproj index 2c99e01..ca4b094 100644 --- a/Gitea.Declarative.Lib/Gitea.Declarative.Lib.fsproj +++ b/Gitea.Declarative.Lib/Gitea.Declarative.Lib.fsproj @@ -19,6 +19,7 @@ + diff --git a/Gitea.Declarative.Test/Gitea.Declarative.Test.fsproj b/Gitea.Declarative.Test/Gitea.Declarative.Test.fsproj index 857b1e7..2c546a7 100644 --- a/Gitea.Declarative.Test/Gitea.Declarative.Test.fsproj +++ b/Gitea.Declarative.Test/Gitea.Declarative.Test.fsproj @@ -7,7 +7,10 @@ + + + diff --git a/Gitea.Declarative.Test/Logging.fs b/Gitea.Declarative.Test/Logging.fs new file mode 100644 index 0000000..af0cdab --- /dev/null +++ b/Gitea.Declarative.Test/Logging.fs @@ -0,0 +1,34 @@ +namespace Gitea.Declarative.Test + +open System +open Microsoft.Extensions.Logging + +[] +module LoggerFactory = + + /// Creates a test ILoggerFactory, a sink whose provided inputs you can access through the `unit -> string list`. + let makeTest () : ILoggerFactory * (unit -> string list) = + let outputs = ResizeArray<_> () + + let lf = + { new ILoggerFactory with + member _.Dispose () = () + + member _.CreateLogger (name : string) = + { new ILogger with + member _.IsEnabled _ = true + + member _.BeginScope _ = + { new IDisposable with + member _.Dispose () = () + } + + member _.Log (_, _, state, exc : exn, formatter) = + let toWrite = formatter.Invoke (state, exc) + lock outputs (fun () -> outputs.Add toWrite) + } + + member _.AddProvider provider = failwith "unsupported" + } + + lf, (fun () -> lock outputs (fun () -> Seq.toList outputs)) diff --git a/Gitea.Declarative.Test/Result.fs b/Gitea.Declarative.Test/Result.fs new file mode 100644 index 0000000..0e8b9b7 --- /dev/null +++ b/Gitea.Declarative.Test/Result.fs @@ -0,0 +1,14 @@ +namespace Gitea.Declarative.Test + +[] +module Result = + + let get r = + match r with + | Ok o -> o + | Error e -> failwithf "Expected Ok, got: %+A" e + + let getError r = + match r with + | Ok o -> failwithf "Expected Error, got: %+A" o + | Error e -> e diff --git a/Gitea.Declarative.Test/TestRepo.fs b/Gitea.Declarative.Test/TestRepo.fs new file mode 100644 index 0000000..9312777 --- /dev/null +++ b/Gitea.Declarative.Test/TestRepo.fs @@ -0,0 +1,234 @@ +namespace Gitea.Declarative.Test + +open System +open System.Threading.Tasks +open Gitea.Declarative +open Gitea.InMemory +open Microsoft.Extensions.Logging.Abstractions +open NUnit.Framework +open FsUnitTyped +open FsCheck + +[] +module TestRepo = + + [] + let ``We refuse to delete a repo if we get to Reconcile without positive confirmation`` () = + let property (gitHubToken : string option) = + let client = GiteaClientMock.Unimplemented + + let lf, messages = LoggerFactory.makeTest () + let logger = lf.CreateLogger "test" + + [ + User "username", Map.ofList [ RepoName "repo", AlignmentError.UnexpectedlyPresent ] + ] + |> Map.ofList + |> Gitea.reconcileRepoErrors logger client gitHubToken + |> Async.RunSynchronously + + messages () + |> List.filter (fun s -> s.Contains ("refusing to delete", StringComparison.OrdinalIgnoreCase)) + |> List.length + |> shouldEqual 1 + + Check.QuickThrowOnFailure property + + [] + let ``We refuse to delete repos when they're not configured to be deleted`` () = + Arb.register () |> ignore + + let property + (user : User) + (repos : Map) + (userInfo : UserInfo) + (repo : Gitea.Repository) + (reposToReturn : Gitea.Repository[]) + = + let reposToReturn = Array.append [| repo |] reposToReturn + + let reposToReturn = + if reposToReturn.Length >= 5 then + reposToReturn.[0..3] + else + reposToReturn + + let lf, messages = LoggerFactory.makeTest () + let logger = lf.CreateLogger "test" + + let client = + { GiteaClientMock.Unimplemented with + UserListRepos = + fun (_username, _page, _limit) -> + async { + return + reposToReturn + |> Array.filter (fun r -> not (repos.ContainsKey (RepoName r.Name))) + } + |> Async.StartAsTask + + RepoListPushMirrors = fun _ -> async { return [||] } |> Async.StartAsTask + + RepoListBranchProtection = fun _ -> async { return [||] } |> Async.StartAsTask + + RepoListCollaborators = fun _ -> async { return [||] } |> Async.StartAsTask + } + + let config : GiteaConfig = + { + Users = Map.ofList [ user, userInfo ] + Repos = + let repos = + repos + |> Map.map (fun _ r -> + { r with + Deleted = + match r.Deleted with + | Some true -> Some false + | _ -> None + } + ) + + [ user, repos ] |> Map.ofList + } + + let recoveredUser, error = + Gitea.checkRepos logger config client + |> Async.RunSynchronously + |> Result.getError + |> Map.toSeq + |> Seq.exactlyOne + + recoveredUser |> shouldEqual user + + for repoName, _configuredRepo in Map.toSeq repos do + match Map.tryFind repoName error with + | Some (AlignmentError.DoesNotExist _) -> () + | a -> failwithf "Failed: %+A" a + + let messages = messages () + messages |> shouldEqual [] + + Check.QuickThrowOnFailure property + + [] + let ``We point out when repos have been deleted`` () = + Arb.register () |> ignore + + let property (user : User) (repos : Map) (userInfo : UserInfo) = + + let lf, messages = LoggerFactory.makeTest () + let logger = lf.CreateLogger "test" + + let client = + { GiteaClientMock.Unimplemented with + UserListRepos = fun _ -> Task.FromResult [||] + + RepoListPushMirrors = fun _ -> async { return [||] } |> Async.StartAsTask + + RepoListBranchProtection = fun _ -> async { return [||] } |> Async.StartAsTask + + RepoListCollaborators = fun _ -> async { return [||] } |> Async.StartAsTask + } + + let config : GiteaConfig = + { + Users = Map.ofList [ user, userInfo ] + Repos = + let repos = + repos + |> Map.map (fun _ r -> + { r with + Deleted = Some true + } + ) + + [ user, repos ] |> Map.ofList + } + + Gitea.checkRepos logger config client |> Async.RunSynchronously |> Result.get + + for message in messages () do + message.Contains ("Remove this repo from configuration", StringComparison.OrdinalIgnoreCase) + |> shouldEqual true + + Check.QuickThrowOnFailure property + + [] + let ``We decide to delete repos which are configured to Deleted = true`` () = + Arb.register () |> ignore + + let property + (user : User) + (oneExistingRepoName : RepoName) + (oneExistingRepo : Repo) + (existingRepos : Map) + (userInfo : UserInfo) + = + + let existingRepos = existingRepos |> Map.add oneExistingRepoName oneExistingRepo + + let giteaUser = + let result = Gitea.User () + result.LoginName <- user.ToString () + result + + let client = + { GiteaClientMock.Unimplemented with + UserListRepos = + fun _ -> + async { + return + existingRepos + |> Map.toSeq + |> Seq.map (fun (RepoName repoName, _repoSpec) -> + let repo = Gitea.Repository () + repo.Name <- repoName + repo.Owner <- giteaUser + repo + ) + |> Seq.toArray + } + |> Async.StartAsTask + + RepoListPushMirrors = fun _ -> async { return [||] } |> Async.StartAsTask + + RepoListBranchProtection = fun _ -> async { return [||] } |> Async.StartAsTask + + RepoListCollaborators = fun _ -> async { return [||] } |> Async.StartAsTask + } + + let config : GiteaConfig = + { + Users = Map.ofList [ user, userInfo ] + Repos = + let repos = + existingRepos + |> Map.map (fun _ r -> + { r with + Deleted = Some true + } + ) + + [ user, repos ] |> Map.ofList + } + + let recoveredUser, errors = + Gitea.checkRepos NullLogger.Instance config client + |> Async.RunSynchronously + |> Result.getError + |> Map.toSeq + |> Seq.exactlyOne + + recoveredUser |> shouldEqual user + + CollectionAssert.AreEqual (existingRepos.Keys, errors.Keys) + + for _repo, config in Map.toSeq errors do + match config with + | AlignmentError.ConfigurationDiffers (desired, _) -> desired.Deleted |> shouldEqual (Some true) + | a -> failwithf "Unexpected alignment: %+A" a + + Check.QuickThrowOnFailure property + +// TODO: test that we delete repos which come up as ConfigurationDiffers (desired.Deleted = Some true) diff --git a/Gitea.Declarative.Test/Utils.fs b/Gitea.Declarative.Test/Utils.fs index 6608e97..21d5362 100644 --- a/Gitea.Declarative.Test/Utils.fs +++ b/Gitea.Declarative.Test/Utils.fs @@ -1,12 +1,111 @@ namespace Gitea.Declarative.Test +open Gitea.Declarative open System open System.IO open FsCheck +open Microsoft.FSharp.Reflection type CustomArb () = static member UriGen = Gen.constant (Uri "http://example.com") |> Arb.fromGen + static member User : Arbitrary = + gen { + let user = Gitea.User () + let! a = Arb.generate<_> + user.Active <- a + let! a = Arb.generate<_> + user.Created <- a + let! a = Arb.generate<_> + user.Description <- a + let! a = Arb.generate<_> + user.Email <- a + let! a = Arb.generate<_> + user.Id <- a + let! a = Arb.generate<_> + user.Language <- a + let! a = Arb.generate<_> + user.Location <- a + let! a = Arb.generate<_> + user.Login <- a + let! a = Arb.generate<_> + user.Restricted <- a + let! a = Arb.generate<_> + user.Visibility <- a + let! a = Arb.generate<_> + user.Website <- a + let! a = Arb.generate<_> + user.FullName <- a + let! a = Arb.generate<_> + user.IsAdmin <- a + let! a = Arb.generate<_> + user.LoginName <- a + let! a = Arb.generate<_> + user.ProhibitLogin <- a + return user + } + |> Arb.fromGen + + static member RepositoryGen : Arbitrary = + gen { + let repo = Gitea.Repository () + let! a = Arb.generate<_> + repo.Archived <- a + let! a = Arb.generate<_> + repo.Description <- a + let! a = Arb.generate<_> + repo.Empty <- a + let! a = Arb.generate<_> + repo.Fork <- a + let! a = Arb.generate<_> + repo.Id <- a + let! a = Arb.generate<_> + repo.Internal <- a + let! a = Arb.generate<_> + repo.Language <- a + let! a = Arb.generate<_> + repo.Link <- a + let! a = Arb.generate<_> + repo.Mirror <- a + let! a = Arb.generate<_> + repo.Name <- a + let! a = Arb.generate<_> + repo.Owner <- a + let! a = Arb.generate<_> + repo.Private <- a + let! a = Arb.generate<_> + repo.Website <- a + let! a = Arb.generate<_> + repo.AllowRebase <- a + let! a = Arb.generate<_> + repo.AllowMergeCommits <- a + let! a = Arb.generate<_> + repo.AllowRebaseExplicit <- a + let! a = Arb.generate<_> + repo.AllowRebaseUpdate <- a + let! a = Arb.generate<_> + repo.AllowSquashMerge <- a + let! a = Arb.generate<_> + repo.DefaultBranch <- a + let! a = Arb.generate<_> + repo.HasIssues <- a + let! a = Arb.generate<_> + repo.HasProjects <- a + let! a = Arb.generate<_> + repo.HasWiki <- a + let! a = Arb.generate<_> + repo.HasPullRequests <- a + + let! a = + FSharpType.GetUnionCases typeof + |> Array.map (fun uci -> FSharpValue.MakeUnion (uci, [||]) |> unbox) + |> Gen.elements + + repo.DefaultMergeStyle <- MergeStyle.toString a + return repo + } + |> Arb.fromGen + [] module Utils = diff --git a/Gitea.InMemory/Client.fs b/Gitea.InMemory/Client.fs index a9cdbae..f967c04 100644 --- a/Gitea.InMemory/Client.fs +++ b/Gitea.InMemory/Client.fs @@ -61,16 +61,7 @@ module Client = member _.AdminCreateUser createUserOption = async { let! () = server.PostAndAsyncReply (fun reply -> AddUser (createUserOption, reply)) - let result = Gitea.User () - result.Email <- createUserOption.Email - result.Restricted <- createUserOption.Restricted - // TODO: what is this username used for anyway - // result.LoginName <- createUserOption.Username - result.Visibility <- createUserOption.Visibility - result.Created <- createUserOption.CreatedAt - result.FullName <- createUserOption.FullName - result.LoginName <- createUserOption.LoginName - return result + return Operations.createdUser createUserOption } |> Async.StartAsTask diff --git a/Gitea.InMemory/Domain.fs b/Gitea.InMemory/Domain.fs index 5cb5c44..5506854 100644 --- a/Gitea.InMemory/Domain.fs +++ b/Gitea.InMemory/Domain.fs @@ -4,27 +4,23 @@ open System open System.Threading.Tasks open Gitea.Declarative -type BranchName = | BranchName of string +module Types = -type BranchProtectionRule = - { - RequiredChecks : string Set - } + type BranchName = | BranchName of string -type NativeRepo = - { - BranchProtectionRules : (BranchName * BranchProtectionRule) list - } + type BranchProtectionRule = + { + RequiredChecks : string Set + } -type Repo = - | GitHubMirror of Uri - | NativeRepo of NativeRepo + type NativeRepo = + { + BranchProtectionRules : (BranchName * BranchProtectionRule) list + } -type GiteaState = - { - Users : User Set - Repositories : Map - } + type Repo = + | GitHubMirror of Uri + | NativeRepo of NativeRepo /// Allows us to use handy record-updating syntax. /// (I have a considerable dislike of Moq and friends.) diff --git a/Gitea.InMemory/Gitea.InMemory.fsproj b/Gitea.InMemory/Gitea.InMemory.fsproj index 7845879..bca339b 100644 --- a/Gitea.InMemory/Gitea.InMemory.fsproj +++ b/Gitea.InMemory/Gitea.InMemory.fsproj @@ -8,6 +8,7 @@ + diff --git a/Gitea.InMemory/Server.fs b/Gitea.InMemory/Server.fs new file mode 100644 index 0000000..08513c9 --- /dev/null +++ b/Gitea.InMemory/Server.fs @@ -0,0 +1,18 @@ +namespace Gitea.InMemory + +open Gitea.Declarative + +[] +module Operations = + let createdUser (createUserOption : Gitea.CreateUserOption) : Gitea.User = + let result = Gitea.User () + result.Email <- createUserOption.Email + result.Restricted <- createUserOption.Restricted + // TODO: what is this username used for anyway + // result.LoginName <- createUserOption.Username + result.Visibility <- createUserOption.Visibility + result.Created <- createUserOption.CreatedAt + result.FullName <- createUserOption.FullName + result.LoginName <- createUserOption.LoginName + + result