@@ -1047,3 +1047,279 @@ func Test_ProjectService_ChangeServiceHost(t *testing.T) {
10471047 "persisted host should be 'appservice'" )
10481048}
10491049
1050+ // Test_ProjectService_TypeValidation_InvalidChangesNotPersisted tests that invalid type changes
1051+ // fail validation and are not persisted to disk.
1052+ func Test_ProjectService_TypeValidation_InvalidChangesNotPersisted (t * testing.T ) {
1053+ mockContext := mocks .NewMockContext (context .Background ())
1054+ temp := t .TempDir ()
1055+
1056+ azdContext := azdcontext .NewAzdContextWithDirectory (temp )
1057+
1058+ // Create initial project with a service
1059+ projectConfig := & project.ProjectConfig {
1060+ Name : "test-project" ,
1061+ Services : map [string ]* project.ServiceConfig {
1062+ "web" : {
1063+ Name : "web" ,
1064+ RelativePath : "./src/web" ,
1065+ Host : project .ContainerAppTarget ,
1066+ Language : project .ServiceLanguageDotNet ,
1067+ },
1068+ },
1069+ }
1070+
1071+ err := project .Save (* mockContext .Context , projectConfig , azdContext .ProjectPath ())
1072+ require .NoError (t , err )
1073+
1074+ loadedConfig , err := project .Load (* mockContext .Context , azdContext .ProjectPath ())
1075+ require .NoError (t , err )
1076+
1077+ // Setup lazy dependencies
1078+ lazyAzdContext := lazy .From (azdContext )
1079+ fileConfigManager := config .NewFileConfigManager (config .NewManager ())
1080+ localDataStore := environment .NewLocalFileDataStore (azdContext , fileConfigManager )
1081+ envManager , err := environment .NewManager (
1082+ mockContext .Container ,
1083+ azdContext ,
1084+ mockContext .Console ,
1085+ localDataStore ,
1086+ nil ,
1087+ )
1088+ require .NoError (t , err )
1089+ lazyEnvManager := lazy .From (envManager )
1090+ lazyProjectConfig := lazy .From (loadedConfig )
1091+
1092+ service := NewProjectService (lazyAzdContext , lazyEnvManager , lazyProjectConfig )
1093+
1094+ t .Run ("Project_SetInfraToInt_ShouldFailAndNotPersist" , func (t * testing.T ) {
1095+ // Try to set "infra" (which should be an object) to an integer
1096+ intValue , err := structpb .NewValue (123 )
1097+ require .NoError (t , err )
1098+
1099+ _ , err = service .SetConfigValue (* mockContext .Context , & azdext.SetProjectConfigValueRequest {
1100+ Path : "infra" ,
1101+ Value : intValue ,
1102+ })
1103+
1104+ // This should fail because "infra" expects a provisioning.Options struct, not an int
1105+ require .Error (t , err , "setting infra to int should fail validation" )
1106+
1107+ // Verify the change was NOT persisted to disk
1108+ reloadedConfig , err := project .Load (* mockContext .Context , azdContext .ProjectPath ())
1109+ require .NoError (t , err )
1110+ require .NotNil (t , reloadedConfig .Infra , "infra should still be valid object" )
1111+ require .Empty (t , reloadedConfig .Infra .Provider , "infra.provider should be empty (default)" )
1112+ })
1113+
1114+ t .Run ("Project_SetInfraProviderToObject_ShouldFailAndNotPersist" , func (t * testing.T ) {
1115+ // Try to set "infra.provider" (which should be a string) to an object
1116+ objectValue , err := structpb .NewStruct (map [string ]interface {}{
1117+ "nested" : "value" ,
1118+ })
1119+ require .NoError (t , err )
1120+
1121+ _ , err = service .SetConfigValue (* mockContext .Context , & azdext.SetProjectConfigValueRequest {
1122+ Path : "infra.provider" ,
1123+ Value : structpb .NewStructValue (objectValue ),
1124+ })
1125+
1126+ // This should fail because "infra.provider" expects a string, not an object
1127+ require .Error (t , err , "setting infra.provider to object should fail validation" )
1128+
1129+ // Verify the change was NOT persisted to disk
1130+ reloadedConfig , err := project .Load (* mockContext .Context , azdContext .ProjectPath ())
1131+ require .NoError (t , err )
1132+ require .Empty (t , reloadedConfig .Infra .Provider , "infra.provider should still be empty" )
1133+ })
1134+
1135+ t .Run ("Project_SetInfraProviderToInt_FailsDuringSet" , func (t * testing.T ) {
1136+ // Try to set "infra.provider" to an int instead of a string
1137+ invalidProvider , err := structpb .NewValue (999 )
1138+ require .NoError (t , err )
1139+
1140+ _ , err = service .SetConfigValue (* mockContext .Context , & azdext.SetProjectConfigValueRequest {
1141+ Path : "infra.provider" ,
1142+ Value : invalidProvider ,
1143+ })
1144+
1145+ // SetConfigValue calls reloadAndCacheProjectConfig which calls project.Load
1146+ // project.Load fails because "999" is not a valid provider
1147+ require .Error (t , err )
1148+ require .Contains (t , err .Error (), "unsupported IaC provider '999'" )
1149+
1150+ // Verify the change was NOT persisted to disk (should still be valid)
1151+ reloadedConfig , err := project .Load (* mockContext .Context , azdContext .ProjectPath ())
1152+ require .NoError (t , err )
1153+ require .Empty (t , reloadedConfig .Infra .Provider )
1154+ })
1155+
1156+ t .Run ("Service_SetHostToInt_CoercesToString" , func (t * testing.T ) {
1157+ // Save the current state
1158+ originalConfig , err := project .Load (* mockContext .Context , azdContext .ProjectPath ())
1159+ require .NoError (t , err )
1160+ originalHost := originalConfig .Services ["web" ].Host
1161+
1162+ // Try to set "host" to an integer instead of a string
1163+ invalidValue , err := structpb .NewValue (789 )
1164+ require .NoError (t , err )
1165+
1166+ _ , err = service .SetServiceConfigValue (* mockContext .Context , & azdext.SetServiceConfigValueRequest {
1167+ ServiceName : "web" ,
1168+ Path : "host" ,
1169+ Value : invalidValue ,
1170+ })
1171+
1172+ // This succeeds at the config level (YAML allows numbers)
1173+ require .NoError (t , err )
1174+
1175+ // YAML coerces 789 to string "789", which is then treated as a custom host value
1176+ // (project.Load doesn't fail on unknown host types, it treats them as custom)
1177+ reloadedConfig , err := project .Load (* mockContext .Context , azdContext .ProjectPath ())
1178+ require .NoError (t , err )
1179+ require .Equal (t , project .ServiceTargetKind ("789" ), reloadedConfig .Services ["web" ].Host )
1180+
1181+ // Restore the original valid configuration
1182+ err = project .Save (* mockContext .Context , originalConfig , azdContext .ProjectPath ())
1183+ require .NoError (t , err )
1184+
1185+ // Verify restoration succeeded
1186+ restoredConfig , err := project .Load (* mockContext .Context , azdContext .ProjectPath ())
1187+ require .NoError (t , err )
1188+ require .Equal (t , originalHost , restoredConfig .Services ["web" ].Host )
1189+ })
1190+
1191+ t .Run ("Service_SetLanguageToArray_ShouldFailAndNotPersist" , func (t * testing.T ) {
1192+ // Get current language value
1193+ originalConfig , err := project .Load (* mockContext .Context , azdContext .ProjectPath ())
1194+ require .NoError (t , err )
1195+ originalLanguage := originalConfig .Services ["web" ].Language
1196+
1197+ // Try to set "language" to an array
1198+ arrayValue , err := structpb .NewList ([]interface {}{"go" , "python" })
1199+ require .NoError (t , err )
1200+
1201+ _ , err = service .SetServiceConfigValue (* mockContext .Context , & azdext.SetServiceConfigValueRequest {
1202+ ServiceName : "web" ,
1203+ Path : "language" ,
1204+ Value : structpb .NewListValue (arrayValue ),
1205+ })
1206+
1207+ // This should fail because "language" expects a string, not an array
1208+ require .Error (t , err , "setting language to array should fail validation" )
1209+
1210+ // Verify the change was NOT persisted to disk
1211+ reloadedConfig , err := project .Load (* mockContext .Context , azdContext .ProjectPath ())
1212+ require .NoError (t , err )
1213+ require .Equal (t , originalLanguage , reloadedConfig .Services ["web" ].Language ,
1214+ "language should still have original value" )
1215+ })
1216+
1217+ t .Run ("Service_SetDockerToInvalidStructure_ShouldSucceedButFailOnReload" , func (t * testing.T ) {
1218+ // Save the current state
1219+ originalConfig , err := project .Load (* mockContext .Context , azdContext .ProjectPath ())
1220+ require .NoError (t , err )
1221+
1222+ // Try to set "docker.path" to an int instead of a string
1223+ invalidPath , err := structpb .NewValue (123 )
1224+ require .NoError (t , err )
1225+
1226+ _ , err = service .SetServiceConfigValue (* mockContext .Context , & azdext.SetServiceConfigValueRequest {
1227+ ServiceName : "web" ,
1228+ Path : "docker.path" ,
1229+ Value : invalidPath ,
1230+ })
1231+
1232+ // This succeeds at the config level (YAML allows numbers)
1233+ require .NoError (t , err , "setting docker.path to int succeeds at config level" )
1234+
1235+ // When we reload, YAML will coerce 123 to string "123", which is technically valid
1236+ // but semantically wrong (not a valid file path)
1237+ reloadedConfig , err := project .Load (* mockContext .Context , azdContext .ProjectPath ())
1238+ require .NoError (t , err , "parsing succeeds because YAML coerces int to string" )
1239+ require .Equal (t , "123" , reloadedConfig .Services ["web" ].Docker .Path , "path is coerced to string '123'" )
1240+
1241+ // Restore the original valid configuration
1242+ err = project .Save (* mockContext .Context , originalConfig , azdContext .ProjectPath ())
1243+ require .NoError (t , err )
1244+
1245+ // Verify restoration succeeded
1246+ restoredConfig , err := project .Load (* mockContext .Context , azdContext .ProjectPath ())
1247+ require .NoError (t , err )
1248+ require .Empty (t , restoredConfig .Services ["web" ].Docker .Path )
1249+ })
1250+ }
1251+
1252+ // Test_ProjectService_TypeValidation_CoercedValues tests YAML type coercion behavior
1253+ func Test_ProjectService_TypeValidation_CoercedValues (t * testing.T ) {
1254+ mockContext := mocks .NewMockContext (context .Background ())
1255+ temp := t .TempDir ()
1256+
1257+ azdContext := azdcontext .NewAzdContextWithDirectory (temp )
1258+
1259+ // Create initial project
1260+ projectConfig := & project.ProjectConfig {
1261+ Name : "test-project" ,
1262+ }
1263+
1264+ err := project .Save (* mockContext .Context , projectConfig , azdContext .ProjectPath ())
1265+ require .NoError (t , err )
1266+
1267+ loadedConfig , err := project .Load (* mockContext .Context , azdContext .ProjectPath ())
1268+ require .NoError (t , err )
1269+
1270+ // Setup lazy dependencies
1271+ lazyAzdContext := lazy .From (azdContext )
1272+ fileConfigManager := config .NewFileConfigManager (config .NewManager ())
1273+ localDataStore := environment .NewLocalFileDataStore (azdContext , fileConfigManager )
1274+ envManager , err := environment .NewManager (
1275+ mockContext .Container ,
1276+ azdContext ,
1277+ mockContext .Console ,
1278+ localDataStore ,
1279+ nil ,
1280+ )
1281+ require .NoError (t , err )
1282+ lazyEnvManager := lazy .From (envManager )
1283+ lazyProjectConfig := lazy .From (loadedConfig )
1284+
1285+ service := NewProjectService (lazyAzdContext , lazyEnvManager , lazyProjectConfig )
1286+
1287+ t .Run ("SetNameToInt_GetsCoercedToString" , func (t * testing.T ) {
1288+ // Try to set "name" (which should be a string) to an integer
1289+ intValue , err := structpb .NewValue (456 )
1290+ require .NoError (t , err )
1291+
1292+ _ , err = service .SetConfigValue (* mockContext .Context , & azdext.SetProjectConfigValueRequest {
1293+ Path : "name" ,
1294+ Value : intValue ,
1295+ })
1296+
1297+ // YAML will coerce the int to a string, so this succeeds
1298+ require .NoError (t , err , "YAML coerces int to string, so this succeeds" )
1299+
1300+ // When loaded as ProjectConfig, it gets coerced to string "456"
1301+ reloadedConfig , err := project .Load (* mockContext .Context , azdContext .ProjectPath ())
1302+ require .NoError (t , err )
1303+ require .Equal (t , "456" , reloadedConfig .Name , "YAML unmarshals int as string '456'" )
1304+ })
1305+
1306+ t .Run ("SetNameToBool_GetsCoercedToString" , func (t * testing.T ) {
1307+ // Try to set "name" to a boolean
1308+ boolValue , err := structpb .NewValue (true )
1309+ require .NoError (t , err )
1310+
1311+ _ , err = service .SetConfigValue (* mockContext .Context , & azdext.SetProjectConfigValueRequest {
1312+ Path : "name" ,
1313+ Value : boolValue ,
1314+ })
1315+
1316+ // YAML will coerce bool to string
1317+ require .NoError (t , err , "YAML coerces bool to string" )
1318+
1319+ // When loaded as ProjectConfig, it gets coerced to string "true"
1320+ reloadedConfig , err := project .Load (* mockContext .Context , azdContext .ProjectPath ())
1321+ require .NoError (t , err )
1322+ require .Equal (t , "true" , reloadedConfig .Name , "YAML unmarshals bool as string 'true'" )
1323+ })
1324+ }
1325+
0 commit comments