From 01ec5954bb6c88f8aadf776a4546f01954abbae6 Mon Sep 17 00:00:00 2001 From: git_admin Date: Mon, 27 Apr 2026 08:45:22 +0000 Subject: [PATCH] Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) --- .../tests/test_yaml_export_wizard.py | 375 ++++++++++++++++++ 1 file changed, 375 insertions(+) create mode 100644 addons/cetmix_tower_yaml/tests/test_yaml_export_wizard.py diff --git a/addons/cetmix_tower_yaml/tests/test_yaml_export_wizard.py b/addons/cetmix_tower_yaml/tests/test_yaml_export_wizard.py new file mode 100644 index 0000000..c757a3a --- /dev/null +++ b/addons/cetmix_tower_yaml/tests/test_yaml_export_wizard.py @@ -0,0 +1,375 @@ +# Copyright (C) 2024 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import base64 + +import yaml + +from odoo.exceptions import AccessError, ValidationError + +from odoo.addons.base.tests.common import BaseCommon + + +class TestYamlExportWizard(BaseCommon): + @classmethod + def setUpClass(cls, *args, **kwargs): + super().setUpClass(*args, **kwargs) + + # Used to ensure that the file header + # is present in the YAML code + cls.file_header = """ +# This file is generated with Cetmix Tower. +# Details and documentation: https://cetmix.com/tower +""" + # Create a command + cls.TowerCommand = cls.env["cx.tower.command"] + cls.command_test_wizard = cls.TowerCommand.create( + { + "reference": "test_command_from_yaml", + "name": "Test Command From Yaml", + "code": "echo 'Test Command From Yaml'", + } + ) + cls.command_test_wizard_2 = cls.TowerCommand.create( + { + "reference": "test_command_from_yaml_2", + "name": "Test Command From Yaml 2", + "code": "echo 'Test Command From Yaml 2'", + } + ) + + # Create a flight plan + cls.FlightPlan = cls.env["cx.tower.plan"] + cls.flight_plan_test_wizard = cls.FlightPlan.create( + { + "name": "Test Flight Plan From Yaml", + "line_ids": [ + ( + 0, + 0, + { + "command_id": cls.command_test_wizard.id, + }, + ) + ], + } + ) + + # Create a server template + cls.ServerTemplate = cls.env["cx.tower.server.template"] + cls.server_template_test_wizard = cls.ServerTemplate.create( + { + "name": "Test Server Template From Yaml", + "flight_plan_id": cls.flight_plan_test_wizard.id, + } + ) + + # Create a wizard and trigger onchange + cls.YamlExportWizard = cls.env["cx.tower.yaml.export.wiz"] + cls.test_wizard = cls.YamlExportWizard.with_context( + active_model="cx.tower.server.template", + active_ids=[cls.server_template_test_wizard.id], + ).create({}) + cls.test_wizard.onchange_explode_child_records() + + def test_user_without_export_group_cannot_export(self): + """Test if user without export group cannot export""" + + # Tower manager user without export group + self.user_yaml_export = self.env["res.users"].create( + { + "name": "No Yaml Export User", + "login": "no_yaml_export_user", + "groups_id": [ + (4, self.env.ref("cetmix_tower_server.group_manager").id) + ], + } + ) + with self.assertRaises(AccessError): + self.test_wizard.with_user(self.user_yaml_export).read([]) + + def test_yaml_export_wizard_yaml_generation(self): + """Test code generation of YAML export wizard.""" + + wizard_yaml = """ +# This file is generated with Cetmix Tower. +# Details and documentation: https://cetmix.com/tower +cetmix_tower_yaml_version: 1 +records: +- cetmix_tower_model: command + access_level: manager + reference: test_command_from_yaml + name: Test Command From Yaml + action: ssh_command + allow_parallel_run: false + note: false + path: false + code: echo 'Test Command From Yaml' + server_status: false + no_split_for_sudo: false + if_file_exists: skip + disconnect_file: false +- cetmix_tower_model: command + access_level: manager + reference: test_command_from_yaml_2 + name: Test Command From Yaml 2 + action: ssh_command + allow_parallel_run: false + note: false + path: false + code: echo 'Test Command From Yaml 2' + server_status: false + no_split_for_sudo: false + if_file_exists: skip + disconnect_file: false +""" + + # -- 1 -- + # Test with two commands + context = { + "default_explode_child_records": True, + "default_remove_empty_values": True, + "active_model": "cx.tower.command", + "active_ids": [self.command_test_wizard.id, self.command_test_wizard_2.id], + } + wizard = self.YamlExportWizard.with_context(context).create({}) # pylint: disable=context-overridden # new need a new clean context + wizard.onchange_explode_child_records() + self.assertEqual(wizard.yaml_code, wizard_yaml) + + def test_yaml_export_wizard(self): + """Test the YAML export wizard.""" + + # -- 1 -- + # Test wizard action + result = self.test_wizard.action_generate_yaml_file() + self.assertEqual( + result["type"], "ir.actions.act_window", "Action should be a window" + ) + self.assertEqual( + result["res_model"], + "cx.tower.yaml.export.wiz.download", + "Result model should be the download wizard", + ) + self.assertTrue(result["res_id"], "Wizard should have been created") + + # -- 2 -- + # Ensure download wizard file name is generated + # based on the record reference + download_wizard = self.env["cx.tower.yaml.export.wiz.download"].browse( + result["res_id"] + ) + self.assertEqual( + download_wizard.yaml_file_name, + f"server_template_{self.server_template_test_wizard.reference}.yaml", + "YAML file name should be generated based on record reference", + ) + + # -- 3 -- + # Decode YAML file and check if it's valid + yaml_file_content = base64.decodebytes(download_wizard.yaml_file).decode( + "utf-8" + ) + self.assertEqual( + yaml_file_content, + self.test_wizard.yaml_code, + "YAML file content should be the same as the original YAML code", + ) + + # -- 4 -- + # Test if empty YAML code is handled correctly + self.test_wizard.yaml_code = "" + with self.assertRaises(ValidationError): + self.test_wizard.action_generate_yaml_file() + + def test_reference_object_uniqueness(self): + """ + Ensure each reference is exported as a full object only once + (other times only as ref). + """ + + # Prepare YAML export for flight_plan with two same commands + self.flight_plan_test_wizard.line_ids = [ + (0, 0, {"command_id": self.command_test_wizard.id}), + (0, 0, {"command_id": self.command_test_wizard.id}), + ] + + # Prepare YAML code + self.test_wizard.onchange_explode_child_records() + yaml_data = yaml.safe_load(self.test_wizard.yaml_code) + + # reference counters + ref_full = set() + ref_refs = set() + + # Recursively walk through the YAML data and count references + def walk(obj): + if isinstance(obj, dict): + ref = obj.get("reference") + # dict only with "reference" = ref, otherwise — full object + if ref: + if list(obj.keys()) == ["reference"]: + ref_refs.add(ref) + else: + ref_full.add(ref) + for v in obj.values(): + walk(v) + elif isinstance(obj, list): + for v in obj: + walk(v) + + # Walk through the YAML data + walk(yaml_data["records"]) + + # Each reference as a full object — only once + for ref in ref_full: + self.assertEqual( + list(ref_full).count(ref), + 1, + f"Reference '{ref}' appears as a full object more than once", + ) + # Check that no full objects appear more than once + self.assertEqual( + len(ref_full), + len(set(ref_full)), + "Some full objects appear more than once", + ) + + # Check that for each ref there is no only reference, but no full object + for ref in ref_refs: + self.assertIn( + ref, + ref_full, + f"Reference '{ref}' is used only as a reference, " + "but no full object present", + ) + + def test_export_required_model_name_in_yaml(self): + """ + Test that the model name is required in the YAML file for each record + """ + # create a command to run flight plan + command_run_flight_plan = self.TowerCommand.create( + { + "name": "Run Flight Plan", + "action": "plan", + "flight_plan_id": self.flight_plan_test_wizard.id, + } + ) + # export 2 commands: command_run_flight_plan and command_test_wizard + wizard = self.YamlExportWizard.with_context( + active_model="cx.tower.command", + active_ids=[command_run_flight_plan.id, self.command_test_wizard.id], + ).create({}) + + wizard.onchange_explode_child_records() + + yaml_data = yaml.safe_load(wizard.yaml_code) + + # check that the model name is present in the YAML file for each record + for record in yaml_data["records"]: + self.assertIn("cetmix_tower_model", record) + + def test_default_yaml_file_name_is_used(self): + """ + Wizard should pre-fill `yaml_file_name` with the auto-generated + value that ends with '.yaml' and contains the model prefix. + """ + wiz = self.YamlExportWizard.with_context( + active_model="cx.tower.command", + active_ids=[self.command_test_wizard.id], + ).create({}) + + default_name = wiz.yaml_file_name + + self.assertFalse( + default_name.endswith(".yaml"), + "Default file name must NO have .yaml suffix", + ) + self.assertIn( + "command_", + default_name, + "Default file name should include model prefix", + ) + + def test_yaml_file_name_is_auto_fixed(self): + """ + When the user assigns an invalid name, wizard should auto-sanitise + it to a safe *basename* (lowercase, underscores, no extension). + """ + wiz = self.YamlExportWizard.with_context( + active_model="cx.tower.command", + active_ids=[self.command_test_wizard.id], + ).create({}) + + # user enters a 'dirty' name with spaces, capitals, symbols + wiz.write({"yaml_file_name": "My File!@# .YAML"}) + + # write() override strips to a basename WITHOUT '.yaml' + self.assertEqual( + wiz.yaml_file_name, + "my_file", + "Wizard field must hold only the cleaned basename, without extension", + ) + + def test_action_generate_appends_extension(self): + """ + When generating the download record, the system must append + the `.yaml` extension to the sanitized basename. + """ + wiz = self.YamlExportWizard.with_context( + active_model="cx.tower.command", + active_ids=[self.command_test_wizard.id], + ).create({}) + wiz.onchange_explode_child_records() + act = wiz.action_generate_yaml_file() + download = self.env["cx.tower.yaml.export.wiz.download"].browse(act["res_id"]) + self.assertTrue(download.yaml_file_name.endswith(".yaml")) + + def test_custom_requires_text(self): + """Creating a template with license 'custom' but no text must fail""" + with self.assertRaises(ValidationError): + self.env["cx.tower.yaml.manifest.tmpl"].create( + { + "name": "Bad Manifest", + "license": "custom", + } + ) + + tmpl_ok = self.env["cx.tower.yaml.manifest.tmpl"].create( + { + "name": "Good Manifest", + "license": "custom", + "license_text": "Custom license terms", + } + ) + self.assertEqual(tmpl_ok.license, "custom") + self.assertEqual(tmpl_ok.license_text, "Custom license terms") + + with self.assertRaises(ValidationError): + self.env["cx.tower.yaml.manifest.tmpl"].create( + { + "name": "Bad Manifest 2", + "license": "custom", + "license_text": " ", + } + ) + + def test_wizard_resets_price_on_license_change(self): + """Wizard must reset price/currency when license changes away from 'custom'""" + wiz = self.YamlExportWizard.new( + { + "manifest_license": "custom", + "manifest_price": 42.0, + "manifest_currency": "EUR", + } + ) + wiz.manifest_license = "agpl-3" + wiz._onchange_manifest_license() + self.assertEqual(wiz.manifest_price, 0.0) + self.assertFalse(wiz.manifest_currency) + + wiz.manifest_price = 7.5 + wiz.manifest_currency = "USD" + wiz.manifest_license = "custom" + wiz._onchange_manifest_license() + self.assertEqual(wiz.manifest_price, 7.5) + self.assertEqual(wiz.manifest_currency, "USD")