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