packages feed

seihou-core-0.11.0.0: test/Seihou/Manifest/UpgradeSpec.hs

module Seihou.Manifest.UpgradeSpec (tests) where

import Control.Lens ((^.))
import Control.Monad (forM_)
import Data.Aeson qualified as Aeson
import Data.Aeson.Key qualified as Key
import Data.Aeson.KeyMap qualified as KeyMap
import Data.Generics.Labels ()
import Data.Map.Strict qualified as Map
import Data.Set qualified as Set
import Data.Text qualified as T
import Seihou.Core.Types
import Seihou.Manifest.Types
import Seihou.Manifest.Upgrade
import Test.Hspec
import Test.Tasty
import Test.Tasty.Hspec (testSpec)

tests :: IO TestTree
tests = testSpec "Seihou.Manifest.Upgrade" spec

v :: Int -> ManifestSchemaVersion
v = ManifestSchemaVersion

-- | A schema-6 document with an additive path, a path with no evidence, an
-- explicit false, and keys this build does not model at every level.
schema6Document :: Aeson.Value
schema6Document =
  Aeson.object
    [ "version" Aeson..= (6 :: Int),
      "generatedAt" Aeson..= ("2026-03-01T10:30:00Z" :: String),
      "modules" Aeson..= ([] :: [Aeson.Value]),
      "variables" Aeson..= Aeson.object [],
      "applications" Aeson..= ([] :: [Aeson.Value]),
      "producerOwned" Aeson..= Aeson.object ["keep" Aeson..= True],
      "files"
        Aeson..= Aeson.object
          [ ".gitignore" Aeson..= record [("additiveOnly", Aeson.Bool True), ("applications", Aeson.toJSON ["a" :: String, "b"])],
            "README.md" Aeson..= record [("futureKey", Aeson.String "survives")],
            "flake.nix" Aeson..= record [("additiveOnly", Aeson.Bool False)]
          ]
    ]
  where
    record extra =
      Aeson.Object
        ( KeyMap.fromList
            ( [ ("hash", Aeson.String "aaa"),
                ("module", Aeson.String "base"),
                ("strategy", Aeson.String "template"),
                ("generatedAt", Aeson.String "2026-03-01T10:30:00Z")
              ]
                <> extra
            )
        )

fileField :: String -> Aeson.Key -> Aeson.Value -> Maybe Aeson.Value
fileField path key (Aeson.Object top) = case KeyMap.lookup "files" top of
  Just (Aeson.Object files) -> case KeyMap.lookup (Key.fromString path) files of
    Just (Aeson.Object record) -> KeyMap.lookup key record
    _ -> Nothing
  _ -> Nothing
fileField _ _ _ = Nothing

spec :: Spec
spec = do
  describe "the step registry" $ do
    it "plans a contiguous path from every supported version to the current one" $
      -- ADR 0014's mechanical gap check: a schema bump without its adjacent
      -- step fails here.
      forM_ [oldestUpgradableManifestVersion ^. #unManifestSchemaVersion .. currentManifestVersion ^. #unManifestSchemaVersion] $ \source ->
        case planManifestUpgrade (v source) currentManifestVersion of
          Left err -> expectationFailure ("no path from schema " <> show source <> ": " <> show err)
          Right steps -> do
            map (^. #fromVersion) steps `shouldBe` map v [source .. currentManifestVersion ^. #unManifestSchemaVersion - 1]
            map (^. #toVersion) steps `shouldBe` map v [source + 1 .. currentManifestVersion ^. #unManifestSchemaVersion]

    it "defines exactly one step per source version" $ do
      let sources = map (^. #fromVersion) manifestUpgradeSteps
      length sources `shouldBe` Map.size (Map.fromList [(s, ()) | s <- sources])

    it "returns an empty path from a version to itself" $
      planManifestUpgrade (v 7) (v 7) `shouldBe` Right []

    it "stops at a requested intermediate target" $
      map (^. #toVersion) <$> planManifestUpgrade (v 5) (v 6) `shouldBe` Right [v 6]

    it "reports a missing adjacent step instead of jumping over it" $ do
      let gappy = filter ((/= v 6) . (^. #fromVersion)) manifestUpgradeSteps
      planUpgradeWith gappy (v 7) (v 5) (v 7) `shouldBe` Left (MissingUpgradeStep (v 6))

    it "rejects targets above current and below the source" $ do
      planManifestUpgrade (v 6) (v 99) `shouldBe` Left (TargetAboveCurrent (v 99))
      planManifestUpgrade (v 7) (v 6) `shouldBe` Left (TargetBelowSource (v 7) (v 6))
      planManifestUpgrade (v 99) (v 99) `shouldBe` Left (SourceNewerThanBinary (v 99))
      planManifestUpgrade (v 0) (v 7) `shouldBe` Left (SourceVersionUnsupported (v 0))

    it "classifies the path conversion as inference-bearing and 6 -> 7 as lossless" $ do
      let kindFrom source = upgradeStepKind . (^. #action) <$> lookup (v source) [(s ^. #fromVersion, s) | s <- manifestUpgradeSteps]
      kindFrom 5 `shouldBe` Just InferenceBearingUpgrade
      kindFrom 6 `shouldBe` Just LosslessUpgrade

  describe "the 10 -> 11 seed ownership step" $ do
    let legacy apps extra =
          Aeson.object
            ( [ "module" Aeson..= ("first" :: String),
                "applications" Aeson..= apps,
                "outcome" Aeson..= ("created" :: String),
                "settledAt" Aeson..= ("2026-03-01T10:30:00Z" :: String)
              ]
                <> extra
            )
        document receipt = case upgradeDocumentLosslessly (v 10) schema6Document of
          Right (_, Aeson.Object top) -> Aeson.Object (KeyMap.insert "seeds" (Aeson.object ["CHANGELOG.md" Aeson..= receipt]) top)
          _ -> error "invalid fixture"
    it "preserves application-only uncertainty and unknown fields losslessly" $ do
      let old = document (legacy (["a", "b"] :: [String]) ["futureKey" Aeson..= True])
      Right (steps, upgraded) <- pure (upgradeDocumentLosslessly (v 11) old)
      map (^. #action) steps `shouldBe` [MakeSeedOwnershipExplicit]
      map (upgradeStepKind . (^. #action)) steps `shouldBe` [LosslessUpgrade]
      case upgraded of
        Aeson.Object top -> do
          KeyMap.lookup "producerOwned" top `shouldBe` Just (Aeson.object ["keep" Aeson..= True])
          KeyMap.lookup "checkoutOutputs" top `shouldBe` Just (Aeson.object [])
          case KeyMap.lookup "seeds" top of
            Just (Aeson.Object seeds) -> case KeyMap.lookup "CHANGELOG.md" seeds of
              Just (Aeson.Object receipt) -> do
                KeyMap.lookup "applications" receipt `shouldBe` Nothing
                KeyMap.lookup "futureKey" receipt `shouldBe` Just (Aeson.Bool True)
                KeyMap.lookup "ownership" receipt `shouldBe` Just (Aeson.object ["kind" Aeson..= ("legacy-applications" :: String), "applications" Aeson..= (["a", "b"] :: [String])])
              _ -> expectationFailure "missing receipt"
            _ -> expectationFailure "missing seeds"
        _ -> expectationFailure "not an object"
      Right decoded <- pure (manifestFromJSON (Aeson.encode upgraded))
      fmap (^. #ownership) (Map.lookup "CHANGELOG.md" (decoded ^. #seeds)) `shouldBe` Just (LegacySeedOwners (Set.fromList [ApplicationId "a", ApplicationId "b"]))
      manifestFromJSON (manifestToJSON decoded) `shouldBe` Right decoded
    it "preserves the old representation on every supported seed schema" $
      forM_ [8, 9, 10] $ \version -> do
        let old = setDocumentSchemaVersion (v version) (document (legacy (["a"] :: [String]) []))
        Right decoded <- pure (manifestFromJSON (Aeson.encode old))
        manifestFromJSON (manifestToJSON decoded) `shouldBe` Right decoded
        fmap (^. #ownership) (Map.lookup "CHANGELOG.md" (decoded ^. #seeds)) `shouldBe` Just (LegacySeedOwners (Set.singleton (ApplicationId "a")))
    it "rejects malformed legacy owners without stamping" $
      forM_ [Aeson.Bool True, Aeson.String "a", Aeson.toJSON [False]] $ \apps ->
        upgradeDocumentLosslessly (v 11) (document (legacy apps [])) `shouldSatisfy` either (const True) (const False)
    it "rejects unknown ownership kinds and duplicate authoritative fields" $ do
      let malformed = [Aeson.object ["kind" Aeson..= ("future" :: String)], Aeson.object ["kind" Aeson..= ("instances" :: String), "owners" Aeson..= True]]
      forM_ malformed $ \ownership -> do
        let raw = setDocumentSchemaVersion (v 11) (document (legacy (["a"] :: [String]) ["ownership" Aeson..= ownership]))
        manifestFromJSON (Aeson.encode raw) `shouldSatisfy` either (const True) (const False)

    it "requires valid tagged ownership without relying on duplicate field rejection" $ do
      let receipt ownership = Aeson.object ["module" Aeson..= ("first" :: String), "outcome" Aeson..= ("created" :: String), "settledAt" Aeson..= ("2026-03-01T10:30:00Z" :: String), "ownership" Aeson..= ownership]
          malformed = [Aeson.object ["kind" Aeson..= ("future" :: String)], Aeson.object ["kind" Aeson..= ("instances" :: String), "owners" Aeson..= True], Aeson.object ["kind" Aeson..= ("instances" :: String), "owners" Aeson..= Aeson.object []], Aeson.object ["kind" Aeson..= ("instances" :: String), "owners" Aeson..= Aeson.object ["a" Aeson..= (["x", "x"] :: [String])]]]
      forM_ malformed $ \ownership ->
        manifestFromJSON (Aeson.encode (setDocumentSchemaVersion (v 11) (document (receipt ownership)))) `shouldSatisfy` either (const True) (const False)

  describe "the 7 -> 8 step" $ do
    it "preserves unrelated raw fields and adds seed receipts" $ do
      Right (_, upgraded) <- pure (upgradeDocumentLosslessly (v 8) schema6Document)
      documentSchemaVersion upgraded `shouldBe` Right (v 8)
      case upgraded of
        Aeson.Object fields -> do
          KeyMap.lookup "seeds" fields `shouldBe` Just (Aeson.object [])
          KeyMap.lookup "producerOwned" fields `shouldBe` Just (Aeson.object ["keep" Aeson..= True])
        _ -> expectationFailure "expected object"
      case Aeson.fromJSON upgraded :: Aeson.Result Manifest of
        Aeson.Success manifest -> manifestFromJSON (manifestToJSON manifest) `shouldBe` Right manifest
        Aeson.Error err -> expectationFailure err
    it "rejects malformed seed containers before stamping" $ do
      let document = Aeson.object ["version" Aeson..= (7 :: Int), "seeds" Aeson..= False]
      upgradeDocumentLosslessly (v 8) document `shouldSatisfy` either (const True) (const False)

  describe "the 6 -> 7 step" $ do
    let upgraded = upgradeDocumentLosslessly (v 7) schema6Document

    it "maps additiveOnly: true to additive-only and absent or false to unknown" $ do
      (fileField ".gitignore" "sharedWriteMode" . snd <$> upgraded) `shouldBe` Right (Just (Aeson.String "additive-only"))
      (fileField "README.md" "sharedWriteMode" . snd <$> upgraded) `shouldBe` Right (Just (Aeson.String "unknown"))
      (fileField "flake.nix" "sharedWriteMode" . snd <$> upgraded) `shouldBe` Right (Just (Aeson.String "unknown"))

    it "removes the schema-6 key and keeps unrelated members at every level" $ do
      (fileField ".gitignore" "additiveOnly" . snd <$> upgraded) `shouldBe` Right Nothing
      (fileField "README.md" "futureKey" . snd <$> upgraded) `shouldBe` Right (Just (Aeson.String "survives"))
      (fileField ".gitignore" "applications" . snd <$> upgraded) `shouldBe` Right (Just (Aeson.toJSON ["a" :: String, "b"]))
      case snd <$> upgraded of
        Right (Aeson.Object top) -> KeyMap.lookup "producerOwned" top `shouldBe` Just (Aeson.object ["keep" Aeson..= True])
        other -> expectationFailure ("unexpected result: " <> show other)

    it "stamps version 7 and reports the one step it ran" $ do
      (documentSchemaVersion . snd =<< upgraded) `shouldBe` Right (v 7)
      (map (^. #fromVersion) . fst <$> upgraded) `shouldBe` Right [v 6]

    it "produces a document the typed decoder reads and round-trips" $
      case upgraded of
        Left err -> expectationFailure (show err)
        Right (_, document) -> case manifestFromJSON (Aeson.encode document) of
          Left err -> expectationFailure err
          Right manifest -> do
            (manifest ^. #version) `shouldBe` v 7
            fmap (^. #sharedWriteMode) (Map.lookup ".gitignore" (manifest ^. #files)) `shouldBe` Just SharedWriteAdditiveOnly
            fmap (^. #sharedWriteMode) (Map.lookup "README.md" (manifest ^. #files)) `shouldBe` Just SharedWriteUnknown
            manifestSupports TargetedAdditiveSharedPathUpdate manifest `shouldBe` True
            manifestFromJSON (manifestToJSON manifest) `shouldBe` Right manifest

    it "is a no-op from 7 to 7" $
      case upgraded of
        Left err -> expectationFailure (show err)
        Right (_, document) -> upgradeDocumentLosslessly (v 7) document `shouldBe` Right ([], document)

    it "rejects a malformed additiveOnly value" $ do
      let bad = Aeson.object ["version" Aeson..= (6 :: Int), "files" Aeson..= Aeson.object ["x" Aeson..= Aeson.object ["additiveOnly" Aeson..= ("yes" :: String)]]]
      case upgradeDocumentLosslessly (v 7) bad of
        Left (MalformedDocument source _) -> source `shouldBe` v 6
        other -> expectationFailure ("expected a malformed-document error, got " <> show other)

    it "refuses to run on a document at another version" $ do
      let sixToSeven = last manifestUpgradeSteps
          atFive = setDocumentSchemaVersion (v 5) schema6Document
      applyLosslessUpgradeStep sixToSeven atFive `shouldBe` Left (StepVersionMismatch sixToSeven (v 5))

  describe "lossless staging" $ do
    it "refuses to cross the inference-bearing 5 -> 6 conversion" $ do
      let atFive = setDocumentSchemaVersion (v 5) schema6Document
      case upgradeDocumentLosslessly (v 7) atFive of
        Left (StepRequiresInference s) -> (s ^. #fromVersion) `shouldBe` v 5
        other -> expectationFailure ("expected the path conversion to block, got " <> show other)

    it "names the explicit upgrade command when it refuses" $
      forM_ [s | s <- manifestUpgradeSteps, s ^. #fromVersion == v 5] $ \step5 ->
        renderManifestUpgradeError (StepRequiresInference step5) `shouldSatisfy` T.isInfixOf "seihou manifest upgrade"