seihou-core-0.11.0.0: test/Seihou/Engine/SeedTransactionSpec.hs
module Seihou.Engine.SeedTransactionSpec (tests) where
import Control.Exception (IOException)
import Data.Either (isLeft)
import Data.Generics.Labels ()
import Data.Map.Strict qualified as Map
import Data.Set qualified as Set
import Data.Text.IO qualified as TIO
import Data.Time (UTCTime (..), fromGregorian)
import Seihou.Core.Migration qualified as Migration
import Seihou.Core.Types hiding (KeepCurrent)
import Seihou.Effect.BaselineStorePure (runBaselineStorePure)
import Seihou.Effect.Filesystem (PathEntry (..))
import Seihou.Effect.FilesystemInterp (runFilesystem)
import Seihou.Engine.Migrate (validateSeedMigration)
import Seihou.Engine.Reconcile
import Seihou.Engine.Seed (SeedDecision (..))
import Seihou.Engine.UpdateTransaction
import Seihou.Manifest.Hash (baselineRefForContent, hashContent)
import Seihou.Manifest.Types (emptyManifest)
import Seihou.Prelude
import System.Directory qualified as D
import System.IO.Temp (withSystemTempDirectory)
import Test.Hspec
import Test.Tasty
import Test.Tasty.Hspec (testSpec)
tests :: IO TestTree
tests = testSpec "seed update transactions" $ do
let now = UTCTime (fromGregorian 2026 10 26) 0
appId = ApplicationId "app"
app = AppliedComposition appId (AppliedModuleTarget "seeder") (LocalOrigin "seeder") Nothing [] Nothing Nothing [AppliedInstanceState "seeder" emptyParentVars (LocalOrigin "seeder") Nothing Map.empty] Map.empty [] now
manifest = emptyManifest now & #applications .~ [app]
claims = Map.singleton appId (Set.singleton "seeder")
createAction = SeedAction "seed.txt" "seeder" claims SeedWrite (Just "seed bytes\n")
createPlan = ReconciliationPlan (Set.singleton appId) (Map.singleton "seed.txt" (FileSeedCreate createAction EntryMissing)) Set.empty
begin root = beginSeedUpdateTransaction root (reconciliationMutationPaths createPlan) (Set.singleton "seed.txt")
receipt = SeedRecord "seeder" (KnownSeedOwners claims) SeedCreated now
priorSeed = manifest & #seeds .~ Map.singleton "seed.txt" receipt
managedOwners = Map.singleton "seed.txt" (DesiredFileOwner "seeder" (Set.singleton appId))
plan root prior ops owners seedClaims =
fmap fst $
runEff $
runFilesystem $
runBaselineStorePure Map.empty $
planSeedReconciliation root root prior (Set.singleton appId) ops owners seedClaims
it "requires explicit adoption of equal seed bytes and records keep-current against the generated baseline" $
withSystemTempDirectory "seed-adoption" $ \root -> do
TIO.writeFile (root </> "seed.txt") "project bytes\n"
Right pending <- plan root priorSeed [WriteFileOp "seed.txt" "candidate bytes\n" Copy] managedOwners Map.empty
unresolvedPaths pending `shouldBe` Set.singleton "seed.txt"
Right kept <- pure (resolveFileConflict "seed.txt" KeepCurrent pending)
Right transaction <- beginSeedUpdateTransaction root (reconciliationMutationPaths kept) Set.empty
Right final <- applyReconciliation transaction kept priorSeed
final ^. #seeds `shouldBe` Map.empty
fmap (^. #hash) (Map.lookup "seed.txt" (final ^. #files)) `shouldBe` Just (hashContent "project bytes\n")
fmap (^. #baseline) (Map.lookup "seed.txt" (final ^. #files)) `shouldBe` Just (Just (baselineRefForContent "candidate bytes\n"))
TIO.readFile (root </> "seed.txt") `shouldReturn` "project bytes\n"
completeUpdateTransaction transaction `shouldReturn` Right ()
it "offers only explicit recreation for a missing seed and accepts generated bytes" $
withSystemTempDirectory "seed-adopt-missing" $ \root -> do
Right pending <- plan root priorSeed [WriteFileOp "seed.txt" "candidate bytes\n" Copy] managedOwners Map.empty
resolveFileConflict "seed.txt" KeepCurrent pending `shouldSatisfy` isLeft
resolveFileConflict "seed.txt" WriteConflictMarkers pending `shouldSatisfy` isLeft
Right accepted <- pure (resolveFileConflict "seed.txt" AcceptGenerated pending)
Right transaction <- beginSeedUpdateTransaction root (reconciliationMutationPaths accepted) Set.empty
Right final <- applyReconciliation transaction accepted priorSeed
final ^. #seeds `shouldBe` Map.empty
TIO.readFile (root </> "seed.txt") `shouldReturn` "candidate bytes\n"
completeUpdateTransaction transaction `shouldReturn` Right ()
it "preserves unselected seed claims when the selected application retires its claim" $
withSystemTempDirectory "seed-partial-retirement" $ \root -> do
let other = app & #applicationId .~ ApplicationId "other"
sharedClaims = Map.insert (ApplicationId "other") (Set.singleton "seeder") claims
prior = priorSeed & #applications %~ (<> [other]) & #seeds .~ Map.singleton "seed.txt" (receipt & #ownership .~ KnownSeedOwners sharedClaims)
D.createDirectory (root </> "seed.txt")
Right pending <- plan root prior [] Map.empty Map.empty
reconciliationMutationPaths pending `shouldBe` Set.empty
Right transaction <- beginSeedUpdateTransaction root Set.empty Set.empty
Right final <- applyReconciliation transaction pending prior
fmap (^. #ownership) (Map.lookup "seed.txt" (final ^. #seeds)) `shouldBe` Just (KnownSeedOwners (Map.delete appId sharedClaims))
D.doesDirectoryExist (root </> "seed.txt") `shouldReturn` True
completeUpdateTransaction transaction `shouldReturn` Right ()
it "does not grant release over an unselected managed owner, even with additive certification" $
withSystemTempDirectory "seed-release-owner" $ \root -> do
let record = FileRecord (hashContent "generated") "seeder" Copy now Nothing (Set.fromList [appId, ApplicationId "other"]) SharedWriteAdditiveOnly
result <- plan root (manifest & #files .~ Map.singleton "seed.txt" record) [SeedFileOp "seed.txt" "seed" Copy "seeder"] Map.empty (Map.singleton "seed.txt" claims)
result `shouldBe` Left (SeedReleaseBlockedByOwners "seed.txt" (Set.singleton (ApplicationId "other")))
it "guards every declarative migration source, destination, ancestor and portable alias" $ do
let protected = Set.singleton "nested/seed.txt"
ops = [Migration.MoveFile "nested/seed.txt" "elsewhere", Migration.MoveFile "elsewhere" "nested/seed.txt", Migration.MoveDir "nested" "other", Migration.MoveDir "other" "nested", Migration.DeleteFile "nested/seed.txt", Migration.DeleteDir "nested", Migration.DeleteFile "NESTED/seed.txt"]
mapM_ (\op -> validateSeedMigration protected [op] `shouldSatisfy` isLeft) ops
validateSeedMigration protected [Migration.DeleteFile "unrelated"] `shouldBe` Right ()
it "releases edited managed files without reading or restoring project bytes" $
withSystemTempDirectory "seed-release-transaction" $ \root -> do
let record = FileRecord (hashContent "generated") "seeder" Copy now Nothing (Set.singleton appId) SharedWriteRequiresOwnershipClosure
prior = manifest & #files .~ Map.singleton "seed.txt" record
action = SeedAction "seed.txt" "seeder" claims SeedReleaseManaged Nothing
plan = ReconciliationPlan (Set.singleton appId) (Map.singleton "seed.txt" (FileSeedRelease action record)) Set.empty
-- A directory cannot be read as text or journaled as an ordinary file;
-- metadata release still preserves this project-owned replacement.
D.createDirectory (root </> "seed.txt")
Right transaction <- beginSeedUpdateTransaction root (reconciliationMutationPaths plan) Set.empty
Right candidate <- applyReconciliation transaction plan prior
candidate ^. #files `shouldBe` Map.empty
fmap (^. #outcome) (Map.lookup "seed.txt" (candidate ^. #seeds)) `shouldBe` Just SeedReleasedFromManaged
rollbackUpdateTransaction transaction `shouldReturn` Right ()
D.doesDirectoryExist (root </> "seed.txt") `shouldReturn` True
it "does not resurrect a missing managed path on release" $
withSystemTempDirectory "seed-release-missing" $ \root -> do
let record = FileRecord (hashContent "generated") "seeder" Copy now Nothing (Set.singleton appId) SharedWriteRequiresOwnershipClosure
action = SeedAction "seed.txt" "seeder" claims SeedReleaseManaged Nothing
plan = ReconciliationPlan (Set.singleton appId) (Map.singleton "seed.txt" (FileSeedRelease action record)) Set.empty
Right transaction <- beginSeedUpdateTransaction root Set.empty Set.empty
Right _ <- applyReconciliation transaction plan (manifest & #files .~ Map.singleton "seed.txt" record)
D.doesPathExist (root </> "seed.txt") `shouldReturn` False
rollbackUpdateTransaction transaction `shouldReturn` Right ()
it "aborts a creation that became occupied without restoring over the new object" $
withSystemTempDirectory "seed-stale" $ \root -> do
Right transaction <- begin root
TIO.writeFile (root </> "seed.txt") "concurrent project file\n"
result <- applyReconciliation transaction createPlan manifest
result `shouldSatisfy` isLeft
TIO.readFile (root </> "seed.txt") `shouldReturn` "concurrent project file\n"
recoverIncompleteTransactions root >>= (`shouldSatisfy` all (== Right ()))
it "rolls back only an unchanged seed actually created by the transaction" $
withSystemTempDirectory "seed-rollback" $ \root -> do
Right transaction <- begin root
result <- applyReconciliationWithHook (\_ -> ioError (userError "injected failure")) transaction createPlan manifest
result `shouldSatisfy` isLeft
D.doesPathExist (root </> "seed.txt") `shouldReturn` False
it "recovers completed creation evidence after restart" $
withSystemTempDirectory "seed-restart" $ \root -> do
Right transaction <- begin root
Right _ <- applyReconciliation transaction createPlan manifest
TIO.readFile (root </> "seed.txt") `shouldReturn` "seed bytes\n"
recoverIncompleteTransactions root >>= (`shouldSatisfy` all (== Right ()))
D.doesPathExist (root </> "seed.txt") `shouldReturn` False
it "preserves later edits and identical replacement objects during failed rollback" $
mapM_
( \replace -> withSystemTempDirectory "seed-rollback-project-edit" $ \root -> do
Right transaction <- begin root
let change _ = do
if replace then D.removeFile (root </> "seed.txt") else pure ()
TIO.writeFile (root </> "seed.txt") (if replace then "seed bytes\n" else "project edit\n")
ioError (userError "injected failure")
result <- applyReconciliationWithHook change transaction createPlan manifest
result `shouldSatisfy` isLeft
D.doesFileExist (root </> "seed.txt") `shouldReturn` True
TIO.readFile (root </> "seed.txt") `shouldReturn` (if replace then "seed bytes\n" else "project edit\n")
)
[False, True]