wireform-proto-0.1.0.0: test-integration/Test/Compat.hs
module Test.Compat (compatTests) where
import Data.Text (Text)
import Data.Text qualified as T
import Proto.Compat
import Proto.IDL.AST
import Proto.IDL.Parser
import Test.Tasty
import Test.Tasty.HUnit
compatTests :: TestTree
compatTests =
testGroup
"Schema Compatibility"
[ testGroup
"BACKWARD compatibility"
[ testCase "identical schemas are compatible" $ do
let schema = parseOrDie simpleSchema
assertCompatible (checkBackward schema schema)
, testCase "adding optional field is backward compatible" $ do
let old =
parseOrDie $
T.unlines
[ "syntax = \"proto3\";"
, "message Msg { string name = 1; }"
]
new =
parseOrDie $
T.unlines
[ "syntax = \"proto3\";"
, "message Msg { string name = 1; int32 age = 2; }"
]
assertCompatible (checkBackward new old)
, testCase "removing field without reserving breaks backward" $ do
let old =
parseOrDie $
T.unlines
[ "syntax = \"proto3\";"
, "message Msg { string name = 1; int32 age = 2; }"
]
new =
parseOrDie $
T.unlines
[ "syntax = \"proto3\";"
, "message Msg { string name = 1; }"
]
assertIncompatible (checkBackward new old)
assertHasRule "FIELD_REMOVED_NOT_RESERVED" (checkBackward new old)
, testCase "removing field with reservation is backward compatible" $ do
let old =
parseOrDie $
T.unlines
[ "syntax = \"proto3\";"
, "message Msg { string name = 1; int32 age = 2; }"
]
new =
parseOrDie $
T.unlines
[ "syntax = \"proto3\";"
, "message Msg { string name = 1; reserved 2; }"
]
assertCompatible (checkBackward new old)
, testCase "changing field type (incompatible wire) breaks backward" $ do
let old =
parseOrDie $
T.unlines
[ "syntax = \"proto3\";"
, "message Msg { int32 value = 1; }"
]
new =
parseOrDie $
T.unlines
[ "syntax = \"proto3\";"
, "message Msg { string value = 1; }"
]
assertIncompatible (checkBackward new old)
assertHasRule "FIELD_TYPE_CHANGED_INCOMPATIBLE" (checkBackward new old)
, testCase "changing between wire-compatible types warns" $ do
let old =
parseOrDie $
T.unlines
[ "syntax = \"proto3\";"
, "message Msg { int32 value = 1; }"
]
new =
parseOrDie $
T.unlines
[ "syntax = \"proto3\";"
, "message Msg { int64 value = 1; }"
]
let result = checkBackward new old
assertCompatible result
assertHasRule "FIELD_TYPE_CHANGED_COMPATIBLE" result
, testCase "adding required field breaks backward" $ do
-- Simulate required by checking the rule directly
let fd = FieldDef () Nothing (Just Required) (FTScalar SInt32) "user_id" (FieldNumber 2) []
oldMsg = MessageDef () Nothing "Msg" [MEField (FieldDef () Nothing Nothing (FTScalar SString) "name" (FieldNumber 1) [])]
newMsg = MessageDef () Nothing "Msg" [MEField (FieldDef () Nothing Nothing (FTScalar SString) "name" (FieldNumber 1) []), MEField fd]
assertIncompatible (checkMessageCompat BackwardDir "Msg" newMsg oldMsg)
assertHasRule "REQUIRED_FIELD_ADDED" (checkMessageCompat BackwardDir "Msg" newMsg oldMsg)
]
, testGroup
"FORWARD compatibility"
[ testCase "adding field is forward compatible (old ignores new)" $ do
let old =
parseOrDie $
T.unlines
[ "syntax = \"proto3\";"
, "message Msg { string name = 1; }"
]
new =
parseOrDie $
T.unlines
[ "syntax = \"proto3\";"
, "message Msg { string name = 1; int32 age = 2; }"
]
assertCompatible (checkForward new old)
, testCase "removing field is forward compatible" $ do
let old =
parseOrDie $
T.unlines
[ "syntax = \"proto3\";"
, "message Msg { string name = 1; int32 age = 2; }"
]
new =
parseOrDie $
T.unlines
[ "syntax = \"proto3\";"
, "message Msg { string name = 1; }"
]
assertCompatible (checkForward new old)
, testCase "type change (incompatible wire) breaks forward" $ do
let old =
parseOrDie $
T.unlines
[ "syntax = \"proto3\";"
, "message Msg { int32 value = 1; }"
]
new =
parseOrDie $
T.unlines
[ "syntax = \"proto3\";"
, "message Msg { double value = 1; }"
]
assertIncompatible (checkForward new old)
]
, testGroup
"FULL compatibility"
[ testCase "adding optional field is full compatible" $ do
let old =
parseOrDie $
T.unlines
[ "syntax = \"proto3\";"
, "message Msg { string name = 1; }"
]
new =
parseOrDie $
T.unlines
[ "syntax = \"proto3\";"
, "message Msg { string name = 1; int32 age = 2; }"
]
assertCompatible (checkFull new old)
, testCase "removing field without reserving breaks full" $ do
let old =
parseOrDie $
T.unlines
[ "syntax = \"proto3\";"
, "message Msg { string name = 1; int32 age = 2; }"
]
new =
parseOrDie $
T.unlines
[ "syntax = \"proto3\";"
, "message Msg { string name = 1; }"
]
assertIncompatible (checkFull new old)
]
, testGroup
"Enum compatibility"
[ testCase "adding enum value warns for forward" $ do
let old =
parseOrDie $
T.unlines
[ "syntax = \"proto3\";"
, "enum Status { UNKNOWN = 0; ACTIVE = 1; }"
]
new =
parseOrDie $
T.unlines
[ "syntax = \"proto3\";"
, "enum Status { UNKNOWN = 0; ACTIVE = 1; INACTIVE = 2; }"
]
let result = checkForward new old
assertCompatible result
assertHasRule "ENUM_VALUE_ADDED" result
, testCase "removing enum value breaks backward" $ do
let old =
parseOrDie $
T.unlines
[ "syntax = \"proto3\";"
, "enum Status { UNKNOWN = 0; ACTIVE = 1; INACTIVE = 2; }"
]
new =
parseOrDie $
T.unlines
[ "syntax = \"proto3\";"
, "enum Status { UNKNOWN = 0; ACTIVE = 1; }"
]
assertIncompatible (checkBackward new old)
assertHasRule "ENUM_VALUE_REMOVED" (checkBackward new old)
, testCase "renaming enum value warns" $ do
let old =
parseOrDie $
T.unlines
[ "syntax = \"proto3\";"
, "enum Status { UNKNOWN = 0; ACTIVE = 1; }"
]
new =
parseOrDie $
T.unlines
[ "syntax = \"proto3\";"
, "enum Status { UNKNOWN = 0; ENABLED = 1; }"
]
assertHasRule "ENUM_VALUE_RENAMED" (checkBackward new old)
]
, testGroup
"Transitive compatibility"
[ testCase "checkCompatAll with multiple versions" $ do
let v1 =
parseOrDie $
T.unlines
[ "syntax = \"proto3\";"
, "message Msg { string name = 1; }"
]
v2 =
parseOrDie $
T.unlines
[ "syntax = \"proto3\";"
, "message Msg { string name = 1; int32 age = 2; }"
]
v3 =
parseOrDie $
T.unlines
[ "syntax = \"proto3\";"
, "message Msg { string name = 1; int32 age = 2; bool active = 3; }"
]
assertCompatible (checkCompatAll BackwardTransitive v3 [v2, v1])
, testCase "transitive fails if any version is incompatible" $ do
let v1 =
parseOrDie $
T.unlines
[ "syntax = \"proto3\";"
, "message Msg { string name = 1; int32 value = 2; }"
]
v2 =
parseOrDie $
T.unlines
[ "syntax = \"proto3\";"
, "message Msg { string name = 1; int32 value = 2; bool active = 3; }"
]
v3 =
parseOrDie $
T.unlines
[ "syntax = \"proto3\";"
, "message Msg { string name = 1; string value = 2; bool active = 3; }"
]
assertIncompatible (checkCompatAll BackwardTransitive v3 [v2, v1])
]
, testGroup
"NONE level"
[ testCase "NONE always passes" $ do
let old =
parseOrDie $
T.unlines
[ "syntax = \"proto3\";"
, "message Msg { string name = 1; }"
]
new =
parseOrDie $
T.unlines
[ "syntax = \"proto3\";"
, "message Msg { double name = 1; }"
]
assertCompatible (checkCompat None new old)
]
, testGroup
"Field name changes"
[ testCase "renaming a field warns" $ do
let old =
parseOrDie $
T.unlines
[ "syntax = \"proto3\";"
, "message Msg { string name = 1; }"
]
new =
parseOrDie $
T.unlines
[ "syntax = \"proto3\";"
, "message Msg { string full_name = 1; }"
]
assertHasRule "FIELD_NAME_CHANGED" (checkBackward new old)
]
, testGroup
"Complex schema evolution"
[ testCase "safe evolution: add fields, reserve removed" $ do
let old =
parseOrDie $
T.unlines
[ "syntax = \"proto3\";"
, "message User {"
, " string name = 1;"
, " string email = 2;"
, " int32 age = 3;"
, "}"
]
new =
parseOrDie $
T.unlines
[ "syntax = \"proto3\";"
, "message User {"
, " string name = 1;"
, " string email = 2;"
, " reserved 3;"
, " string phone = 4;"
, " bool active = 5;"
, "}"
]
assertCompatible (checkFull new old)
]
]
-- Helpers
simpleSchema :: Text
simpleSchema =
T.unlines
[ "syntax = \"proto3\";"
, "message Msg {"
, " string name = 1;"
, " int32 value = 2;"
, "}"
]
parseOrDie :: Text -> ProtoFile
parseOrDie src = case parseProtoFile "<test>" src of
Left err -> error ("Parse failed: " <> show err)
Right pf -> pf
assertCompatible :: CompatResult -> IO ()
assertCompatible result =
assertBool
("Expected compatible, got errors: " <> show (compatErrors result))
(isCompatible result)
assertIncompatible :: CompatResult -> IO ()
assertIncompatible result =
assertBool
"Expected incompatible, but was compatible"
(not (isCompatible result))
assertHasRule :: Text -> CompatResult -> IO ()
assertHasRule rule result =
assertBool
("Expected rule '" <> T.unpack rule <> "' in errors: " <> show (compatErrors result))
(any (\e -> ceRule e == rule) (compatErrors result))