diff --git a/.dockerignore b/.dockerignore
index 7943f8f97c330d906d6331a7c6a7960d5331459b..5ccb441a22c2e293d1c4dbe69e478235e5f65824 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -16,4 +16,13 @@ __pycache__/
*.pyc
*.db
.env
-.git/
\ No newline at end of file
+.git/
+.venv/
+venv/
+*.venv/
+.pytest_cache/
+.coverage
+htmlcov/
+*.egg-info/
+.idea/
+.vscode/
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 5207b65c505b160c6c946bb78baf78391680399e..7f029220c43d52816cbbb2050036a237b1f18dca 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,4 +18,7 @@ src/__pycache__/
venv/
.env
slice.db
+service.db
+telemetry_client.db
+alert.db
.python-version
diff --git a/src/api/main.py b/src/api/main.py
index 61248537264e54d118d070e97be138a4e0105e55..f28ebed386d6d00826943c875864dc6175bd3a44 100644
--- a/src/api/main.py
+++ b/src/api/main.py
@@ -27,6 +27,7 @@ from src.database.sysrepo_store import get_data_store, create_data_store, delete
from typing import Dict, Tuple
from src.realizer.restconf.connectors.tfs_connector import tfs_connector as tfs_restconf_connector
from src.planner.shortest_path import get_shortest_path
+from src.realizer.tfs.service_types.tfs_l2vpn import tfs_l2vpn_delete
@@ -56,6 +57,8 @@ class Api:
result = self.slice_service.nsc(intent)
if not result:
return send_response(False, code=404, message="No intents found")
+ if isinstance(result, tuple):
+ return result
logging.info(f"Slice created successfully")
return send_response(
True,
@@ -358,6 +361,8 @@ class Api:
"""
try:
result = self.slice_service.nsc(intent, slice_id)
+ if isinstance(result, tuple):
+ return result
if not result:
return send_response(False, code=404, message="Slice not found")
logging.info(f"Slice {slice_id} modified successfully")
@@ -435,7 +440,7 @@ class Api:
logging.warning(f"Slice type not found in slice intent. Defaulting to L2")
tfs_connector().nbi_delete(current_app.config["TFS_IP"],slice_type, slice.get("slice_id"))
if current_app.config["TFS_L2VPN_SUPPORT"]:
- self.slice_service.tfs_l2vpn_delete()
+ tfs_l2vpn_delete()
# Clear slice database
delete_all_data()
@@ -532,8 +537,13 @@ class Api:
def add_network_slice_service(self, intent):
try:
result = self.slice_service.nsc(intent)
+ if isinstance(result, tuple):
+ return result
if result:
- create_data_store(intent)
+ try:
+ create_data_store(intent)
+ except Exception as ds_err:
+ logging.warning(f"Could not store intent in sysrepo datastore: {ds_err}")
logging.info(f"Network Slice created successfully")
return send_response(
True,
@@ -904,18 +914,15 @@ class Api:
if not existing_slice:
raise ValueError("Slice not found")
if not current_app.config["DUMMY_MODE"]:
- slice_type = list(existing_slice["network-slice-services"]["slice-service"][slice_id]["service-tags"]["tag-type"]["ietf-network-slice-service:service"]["tag-type-value"])[0]
- if not slice_type:
- slice_type = "L2"
- logging.warning(f"Slice type not found in slice intent. Defaulting to L2")
+ slice_type = safe_get(existing_slice, ["network-slice-services", "slice-service", slice_id, "service-tags", "tag-type", "ietf-network-slice-service:service", "tag-type-value", 0]) or "L2"
logging.debug(f"Send slice to delete in TFS with slice_type {slice_type}")
- services = get_data_by_slice_id(existing_slice.get("id"))
+ services = get_data_by_slice_id(slice_id)
for service in services:
id = service.get("service_id")
tfs_connector().nbi_delete(current_app.config["RESTCONF_IP"], slice_type, id)
- delete_by_slice_id(slice.get("id"))
+ delete_by_slice_id(slice_id)
if current_app.config["TFS_L2VPN_SUPPORT"]:
- self.slice_service.tfs_l2vpn_delete()
+ tfs_l2vpn_delete()
delete_data_store(xpath)
logging.info(f"Slice {slice_id} removed successfully")
@@ -930,10 +937,7 @@ class Api:
if not slice_services:
raise ValueError("Slice services not found")
for slice in slice_services:
- slice_type = list(slice["service-tags"]["tag-type"]["ietf-network-slice-service:service"]["tag-type-value"])[0]
- if not slice_type:
- slice_type = "L2"
- logging.warning(f"Slice type not found in slice intent. Defaulting to L2")
+ slice_type = safe_get(slice, ["service-tags", "tag-type", "ietf-network-slice-service:service", "tag-type-value", 0]) or "L2"
logging.debug(f"Send slice to delete in TFS with slice_type {slice_type}")
services = get_data_by_slice_id(slice.get("id"))
for service in services:
@@ -941,7 +945,7 @@ class Api:
tfs_connector().nbi_delete(current_app.config["RESTCONF_IP"], slice_type, id)
delete_by_slice_id(slice.get("id"))
if current_app.config["TFS_L2VPN_SUPPORT"]:
- self.slice_service.tfs_l2vpn_delete()
+ tfs_l2vpn_delete()
delete_data_store(xpath)
logging.info("All slices removed successfully")
return {}, 204
diff --git a/src/database/service_db.py b/src/database/service_db.py
index e897bab529e721b8cd7a1a32e118327ce3c88a64..0e2aa1fb94aa58216a1a5510a9029b4d15acd52d 100644
--- a/src/database/service_db.py
+++ b/src/database/service_db.py
@@ -61,24 +61,26 @@ def save_data(service_id: str, slice_id: str):
# Update data in the database
def update_data(service_id: str, new_slice_id: str):
"""
- Update an existing service entry in the database.
+ Update the slice_id for an existing service entry in the database.
Args:
- service_id (str): Unique identifier for the service
- slice_id (str): Unique identifier for the slice
-
+ service_id (str): Unique identifier for the service to update
+ new_slice_id (str): New slice ID to associate with the service
+
Raises:
ValueError: If no service is found with the given service_id
"""
conn = sqlite3.connect(DB_NAME)
cursor = conn.cursor()
- cursor.execute("UPDATE service SET slice_id = ? WHERE service_id = ?", (new_slice_id, service_id))
- if cursor.rowcount == 0:
- raise ValueError(f"No slice found with id '{service_id}' to update.")
- else:
- logging.debug(f"Slice '{service_id}' updated.")
- conn.commit()
- conn.close()
+ try:
+ cursor.execute("UPDATE service SET slice_id = ? WHERE service_id = ?", (new_slice_id, service_id))
+ if cursor.rowcount == 0:
+ raise ValueError(f"No slice found with id '{service_id}' to update.")
+ else:
+ logging.debug(f"Slice '{service_id}' updated.")
+ conn.commit()
+ finally:
+ conn.close()
# Delete data from the database
def delete_data(service_id: str):
@@ -87,19 +89,21 @@ def delete_data(service_id: str):
Args:
service_id (str): Unique identifier for the service to delete
-
+
Raises:
ValueError: If no service is found with the given service_id
"""
conn = sqlite3.connect(DB_NAME)
cursor = conn.cursor()
- cursor.execute("DELETE FROM service WHERE service_id = ?", (service_id,))
- if cursor.rowcount == 0:
- raise ValueError(f"No service found with id '{service_id}' to delete.")
- else:
- logging.debug(f"Service '{service_id}' deleted.")
- conn.commit()
- conn.close()
+ try:
+ cursor.execute("DELETE FROM service WHERE service_id = ?", (service_id,))
+ if cursor.rowcount == 0:
+ raise ValueError(f"No service found with id '{service_id}' to delete.")
+ else:
+ logging.debug(f"Service '{service_id}' deleted.")
+ conn.commit()
+ finally:
+ conn.close()
# Get data from the database
def get_data(service_id: str) -> dict[str, str]:
diff --git a/src/database/sysrepo_store.py b/src/database/sysrepo_store.py
index 15cb8743da2faa326b632d6ed21f9b9a7a80ec84..4a4128690a5ac30c2faa36049b27d45b397e2849 100644
--- a/src/database/sysrepo_store.py
+++ b/src/database/sysrepo_store.py
@@ -255,13 +255,29 @@ def _write_dict(sess, base_xpath, data, parent_key=None):
key_field = LIST_KEYS.get(list_name)
for item in data:
- if key_field and isinstance(item, dict) and key_field in item:
- key_value = item[key_field]
- item_xpath = f"{base_xpath}[{key_field}='{key_value}']"
- logging.debug(f"Using key '{key_field}={key_value}' for list '{list_name}'")
+ if item is None:
+ continue
+ item_key_field = key_field
+ if (not item_key_field or item_key_field not in item) and isinstance(item, dict):
+ for candidate in ["sliceProfileId", "serviceProfileId", "id", "uuid", "name"]:
+ if candidate in item:
+ item_key_field = candidate
+ break
+ if not item_key_field:
+ for k in item.keys():
+ if k.lower().endswith("id"):
+ item_key_field = k
+ break
+ if not item_key_field and item:
+ item_key_field = list(item.keys())[0]
+
+ if item_key_field and isinstance(item, dict) and item_key_field in item:
+ key_value = item[item_key_field]
+ item_xpath = f"{base_xpath}[{item_key_field}='{key_value}']"
+ logging.debug(f"Using key '{item_key_field}={key_value}' for list '{list_name}'")
# Pass the key_field to be excluded when processing the item
- _write_dict(sess, item_xpath, item, parent_key=key_field)
+ _write_dict(sess, item_xpath, item, parent_key=item_key_field)
else:
logging.error(f"ERROR: No key '{key_field}' found in item for list '{list_name}'")
logging.error(f"Available keys in item: {list(item.keys()) if isinstance(item, dict) else 'N/A'}")
diff --git a/src/mapper/main.py b/src/mapper/main.py
index a936dfafb094eea3a7a4ec1a24a527ce27d95cc6..30577e298af4d8d21ab9d553666161b776c10664 100644
--- a/src/mapper/main.py
+++ b/src/mapper/main.py
@@ -99,14 +99,14 @@ def mapper(payload, controller_type="TFS", action="CREATE"):
available_templates = safe_get(normalized_templates, ["slo-sle-templates", "slo-sle-template"]) or []
# Add templates from intent
- for template in safe_get(ietf_intent, ["ietf-network-slice-service:network-slice-services","slo-sle-templates", "slo-sle-template"]):
+ for template in (safe_get(ietf_intent, ["ietf-network-slice-service:network-slice-services","slo-sle-templates", "slo-sle-template"]) or []):
available_templates.append(template)
logging.debug(f"Available templates: {available_templates}")
services = []
# Process each slice service
- for slice_service in safe_get(ietf_intent, ["ietf-network-slice-service:network-slice-services", "slice-service"]):
+ for slice_service in (safe_get(ietf_intent, ["ietf-network-slice-service:network-slice-services", "slice-service"]) or []):
service_id = safe_get(slice_service, ["id"])
logging.debug(f"Service ID: {service_id}")
diff --git a/src/mapper/process_connnectivity.py b/src/mapper/process_connnectivity.py
index b057ce163d805e8c1d75cf717e70a807dbcaeb1a..8dc1209440ac442fe92866936105c33622e704ba 100644
--- a/src/mapper/process_connnectivity.py
+++ b/src/mapper/process_connnectivity.py
@@ -31,7 +31,8 @@ def process_connectivity(connection_group_id, connectivity_type, connectivity_co
"""
sdps = []
if connectivity_type == "ietf-vpn-common:any-to-any":
- for sdp in safe_get(connectivity_construct, ["a2a-sdp"]):
+ a2a_list = safe_get(connectivity_construct, ["a2a-sdp"]) or []
+ for sdp in a2a_list:
sdp, match_criteria = extract_sdp_info(sdp, slice_service, connection_group_id, connectivity_construct_id)
sdp = {
"sdp": sdp,
diff --git a/src/realizer/restconf/restconf_connect.py b/src/realizer/restconf/restconf_connect.py
index 9678e4fef751d7ac33ac2a2492e71518717b5a34..e9d880f10ea018a0a06d40659bf61c9c6826f458 100644
--- a/src/realizer/restconf/restconf_connect.py
+++ b/src/realizer/restconf/restconf_connect.py
@@ -52,6 +52,7 @@ def restconf_connect(requests, restconf_ip):
Returns:
response (requests.Response): Response from TFS controller
"""
+ response = None
for intent in requests["services"]:
if current_app.config["SDN_CONTROLLER_TYPE"] == "TFS":
key = next(iter(intent))
@@ -96,5 +97,9 @@ def restconf_connect(requests, restconf_ip):
if not response.ok:
return send_response(False, code=response.status_code,
message=f"Controller upload failed. Response: {response.text}")
+ else:
+ return send_response(False, code=400, message=f"Unsupported SDN controller type: {current_app.config['SDN_CONTROLLER_TYPE']}")
+ if response is None:
+ return send_response(True, code=200, message="No services processed")
return response
\ No newline at end of file
diff --git a/src/realizer/tfs/service_types/tfs_l2vpn.py b/src/realizer/tfs/service_types/tfs_l2vpn.py
index e2a9a669f58863f6cb4ecd0bb4bb2ca74cd47919..972545913c3a6782dedff78fa234bbcf39c8ee6e 100644
--- a/src/realizer/tfs/service_types/tfs_l2vpn.py
+++ b/src/realizer/tfs/service_types/tfs_l2vpn.py
@@ -133,26 +133,32 @@ def tfs_l2vpn_support(requests):
"config":[]
}
for request in requests:
- # Configure Source Endpoint
- temp_source = request["service_config"]["config_rules"][1]["custom"]["resource_value"]
- endpoints = request["service_endpoint_ids"]
+ config_rules = safe_get(request, ["service_config", "config_rules"]) or []
+ if len(config_rules) < 3:
+ continue
+ temp_source = safe_get(config_rules[1], ["custom", "resource_value"])
+ temp_destiny = safe_get(config_rules[2], ["custom", "resource_value"])
+ if not isinstance(temp_source, dict) or "remote_router" not in temp_source:
+ continue
+ if not isinstance(temp_destiny, dict) or "remote_router" not in temp_destiny:
+ continue
+
+ endpoints = request.get("service_endpoint_ids", [{}, {}])
config = {
- "ni_name": temp_source["ni_name"],
- "remote_router": temp_source["remote_router"],
- "interface": endpoints[0]["endpoint_uuid"]["uuid"].replace("0/0/0-", ""),
- "vlan" : temp_source["vlan_id"],
- "number" : temp_source["vlan_id"] % 10 + 1
+ "ni_name": temp_source.get("ni_name", ""),
+ "remote_router": temp_source.get("remote_router", ""),
+ "interface": endpoints[0].get("endpoint_uuid", {}).get("uuid", "").replace("0/0/0-", ""),
+ "vlan" : temp_source.get("vlan_id", 0),
+ "number" : temp_source.get("vlan_id", 0) % 10 + 1
}
sources["config"].append(config)
- # Configure Destination Endpoint
- temp_destiny = request["service_config"]["config_rules"][2]["custom"]["resource_value"]
config = {
- "ni_name": temp_destiny["ni_name"],
- "remote_router": temp_destiny["remote_router"],
- "interface": endpoints[1]["endpoint_uuid"]["uuid"].replace("0/0/3-", ""),
- "vlan" : temp_destiny["vlan_id"],
- "number" : temp_destiny["vlan_id"] % 10 + 1
+ "ni_name": temp_destiny.get("ni_name", ""),
+ "remote_router": temp_destiny.get("remote_router", ""),
+ "interface": endpoints[1].get("endpoint_uuid", {}).get("uuid", "").replace("0/0/3-", ""),
+ "vlan" : temp_destiny.get("vlan_id", 0),
+ "number" : temp_destiny.get("vlan_id", 0) % 10 + 1
}
destinations["config"].append(config)
diff --git a/src/templates/ietf_template.json b/src/templates/ietf_template.json
new file mode 100644
index 0000000000000000000000000000000000000000..74492a740d8bb5cc629852e76418cea7edf02312
--- /dev/null
+++ b/src/templates/ietf_template.json
@@ -0,0 +1,174 @@
+[
+ {
+ "ietf-network-slice-service:network-slice-services": {
+ "slo-sle-templates": {
+ "slo-sle-template": [
+ {
+ "id": "A",
+ "description": "",
+ "slo-policy": {
+ "metric-bound": [
+ {
+ "metric-type": "one-way-bandwidth",
+ "metric-unit": "kbps",
+ "bound": 2000
+ },
+ {
+ "metric-type": "one-way-delay-maximum",
+ "metric-unit": "milliseconds",
+ "bound": 5
+ }
+ ]
+ },
+ "sle-policy": {
+ "security": "",
+ "isolation": "",
+ "path-constraints": {
+ "service-functions": "",
+ "diversity": {
+ "diversity": {
+ "diversity-type": ""
+ }
+ }
+ }
+ }
+ }
+ ]
+ },
+ "slice-service": [
+ {
+ "id": "slice-service-11327140-7361-41b3-aa45-e84a7fb40be9",
+ "description": "Transport network slice mapped with 3GPP slice NetworkSlice1",
+ "service-tags": {
+ "tag-type": [
+ {
+ "tag-type": "service",
+ "tag-type-value": [
+ "L2"
+ ]
+ }
+ ]
+ },
+ "slo-sle-template": "A",
+ "status": {},
+ "sdps": {
+ "sdp": [
+ {
+ "id": "CU-N2",
+ "geo-location": "",
+ "node-id": "CU-N2",
+ "sdp-ip-address": "10.60.11.3",
+ "tp-ref": "",
+ "service-match-criteria": {
+ "match-criterion": [
+ {
+ "index": 1,
+ "match-type": [
+ {
+ "type": "vlan",
+ "vlan": [
+ 100
+ ]
+ }
+ ],
+ "target-connection-group-id": "CU-N2_AMF-N2"
+ }
+ ]
+ },
+ "incoming-qos-policy": "",
+ "outgoing-qos-policy": "",
+ "sdp-peering": {
+ "peer-sap-id": "",
+ "protocols": ""
+ },
+ "ac-svc-ref": [],
+ "attachment-circuits": {
+ "attachment-circuit": [
+ {
+ "id": "100",
+ "ac-ipv4-address": "10.60.11.3",
+ "ac-ipv4-prefix-length": 0,
+ "sdp-peering": {
+ "peer-sap-id": "1.1.1.1"
+ },
+ "status": {}
+ }
+ ]
+ },
+ "status": {},
+ "sdp-monitoring": ""
+ },
+ {
+ "id": "AMF-N2",
+ "geo-location": "",
+ "node-id": "AMF-N2",
+ "sdp-ip-address": "10.60.60.105",
+ "tp-ref": "",
+ "service-match-criteria": {
+ "match-criterion": [
+ {
+ "index": 1,
+ "match-type": [
+ {
+ "type": "vlan",
+ "vlan": [
+ 100
+ ]
+ }
+ ],
+ "target-connection-group-id": "CU-N2_AMF-N2"
+ }
+ ]
+ },
+ "incoming-qos-policy": "",
+ "outgoing-qos-policy": "",
+ "sdp-peering": {
+ "peer-sap-id": "",
+ "protocols": ""
+ },
+ "ac-svc-ref": [],
+ "attachment-circuits": {
+ "attachment-circuit": [
+ {
+ "id": "200",
+ "ac-ipv4-address": "10.60.60.105",
+ "ac-ipv4-prefix-length": 0,
+ "sdp-peering": {
+ "peer-sap-id": "3.3.3.3"
+ },
+ "status": {}
+ }
+ ]
+ },
+ "status": {},
+ "sdp-monitoring": ""
+ }
+ ]
+ },
+ "connection-groups": {
+ "connection-group": [
+ {
+ "id": "CU-N2_AMF-N2",
+ "connectivity-type": "ietf-vpn-common:any-to-any",
+ "connectivity-construct": [
+ {
+ "id": 1,
+ "a2a-sdp": [
+ {
+ "sdp-id": "01"
+ },
+ {
+ "sdp-id": "02"
+ }
+ ]
+ }
+ ],
+ "status": {}
+ }
+ ]
+ }
+ }
+ ]
+ }
+ }
+]
\ No newline at end of file
diff --git a/src/tests/requests/ietf_green_request.json b/src/templates/nbi_template.json
similarity index 64%
rename from src/tests/requests/ietf_green_request.json
rename to src/templates/nbi_template.json
index b0165037a79df96cdc3570f14250ae79bb3e693e..3110ebf03f557d6bbc3c411b5368a0a5989d87c3 100644
--- a/src/tests/requests/ietf_green_request.json
+++ b/src/templates/nbi_template.json
@@ -3,29 +3,19 @@
"slo-sle-templates": {
"slo-sle-template": [
{
- "id": "B",
+ "id": "A",
"description": "",
"slo-policy": {
"metric-bound": [
{
- "metric-type": "energy_consumption",
- "metric-unit": "kWh",
- "bound": 20200
+ "metric-type": "one-way-bandwidth",
+ "metric-unit": "kbps",
+ "bound": 2000
},
{
- "metric-type": "energy_efficiency",
- "metric-unit": "Wats/bps",
- "bound": 6
- },
- {
- "metric-type": "carbon_emission",
- "metric-unit": "grams of CO2 per kWh",
- "bound": 750
- },
- {
- "metric-type": "renewable_energy_usage",
- "metric-unit": "rate",
- "bound": 0.5
+ "metric-type": "one-way-delay-maximum",
+ "metric-unit": "milliseconds",
+ "bound": 5
}
]
},
@@ -46,39 +36,41 @@
},
"slice-service": [
{
- "id": "slice-service-88a585f7-a432-4312-8774-6210fb0b2342",
+ "id": "slice-service-11327140-7361-41b3-aa45-e84a7fb40be9",
"description": "Transport network slice mapped with 3GPP slice NetworkSlice1",
"service-tags": {
- "tag-type": [
- {
- "tag-type": "service",
- "tag-type-value": [
- "L2"
- ]
- }
- ]
+ "tag-type": [
+ {
+ "tag-type": "service",
+ "tag-type-value": [
+ "L2"
+ ]
+ }
+ ]
},
- "slo-sle-template": "B",
+ "slo-sle-template": "A",
"status": {},
"sdps": {
"sdp": [
{
- "id": "A",
+ "id": "CU-N2",
"geo-location": "",
- "node-id": "CU-N32",
+ "node-id": "CU-N2",
"sdp-ip-address": "10.60.11.3",
"tp-ref": "",
"service-match-criteria": {
"match-criterion": [
{
"index": 1,
- "match-type": [
- {
- "type": "vlan",
- "vlan": [101]
- }
- ],
- "target-connection-group-id": "CU-N32_UPF-N32"
+ "match-type": [
+ {
+ "type": "vlan",
+ "vlan": [
+ 100
+ ]
+ }
+ ],
+ "target-connection-group-id": "CU-N2_AMF-N2"
}
]
},
@@ -96,7 +88,7 @@
"ac-ipv4-address": "10.60.11.3",
"ac-ipv4-prefix-length": 0,
"sdp-peering": {
- "peer-sap-id": "4.4.4.4"
+ "peer-sap-id": "1.1.1.1"
},
"status": {}
}
@@ -106,22 +98,24 @@
"sdp-monitoring": ""
},
{
- "id": "B",
+ "id": "AMF-N2",
"geo-location": "",
- "node-id": "UPF-N32",
- "sdp-ip-address": "10.60.10.6",
+ "node-id": "AMF-N2",
+ "sdp-ip-address": "10.60.60.105",
"tp-ref": "",
"service-match-criteria": {
"match-criterion": [
{
"index": 1,
- "match-type": [
- {
- "type": "vlan",
- "vlan": [101]
- }
- ],
- "target-connection-group-id": "CU-N32_UPF-N32"
+ "match-type": [
+ {
+ "type": "vlan",
+ "vlan": [
+ 100
+ ]
+ }
+ ],
+ "target-connection-group-id": "CU-N2_AMF-N2"
}
]
},
@@ -136,10 +130,10 @@
"attachment-circuit": [
{
"id": "200",
- "ac-ipv4-address": "10.60.10.6",
+ "ac-ipv4-address": "10.60.60.105",
"ac-ipv4-prefix-length": 0,
"sdp-peering": {
- "peer-sap-id": "5.5.5.5"
+ "peer-sap-id": "3.3.3.3"
},
"status": {}
}
@@ -153,17 +147,17 @@
"connection-groups": {
"connection-group": [
{
- "id": "CU-N32_UPF-N32",
+ "id": "CU-N2_AMF-N2",
"connectivity-type": "ietf-vpn-common:any-to-any",
"connectivity-construct": [
{
"id": 1,
"a2a-sdp": [
{
- "sdp-id": "A"
+ "sdp-id": "01"
},
{
- "sdp-id": "B"
+ "sdp-id": "02"
}
]
}
diff --git a/src/templates/realizer_template.json b/src/templates/realizer_template.json
new file mode 100644
index 0000000000000000000000000000000000000000..f5ee205a0fbab02435325b3e6d960a6687a65b6c
--- /dev/null
+++ b/src/templates/realizer_template.json
@@ -0,0 +1,37 @@
+{
+ "services": [
+ {
+ "ietf-l2vpn-svc:vpn-service": [
+ {
+ "vpn-id": "11327140-7361-41b3-aa45-e84a7fb40be9",
+ "customer-name": "osm",
+ "vpn-svc-type": "vpws",
+ "svc-topo": "any-to-any",
+ "site": [
+ {
+ "site-id": "1.1.1.1",
+ "site-location": "CU-N2",
+ "site-network-access": {
+ "interface": {
+ "ip-address": "10.60.11.3",
+ "encapsulation": "ethernet"
+ }
+ }
+ },
+ {
+ "site-id": "3.3.3.3",
+ "site-location": "AMF-N2",
+ "site-network-access": {
+ "interface": {
+ "ip-address": "10.60.60.105",
+ "encapsulation": "ethernet"
+ }
+ }
+ }
+ ]
+ }
+ ],
+ "path": "restconf/data/ietf-l2vpn-svc:l2vpn-svc/vpn-services"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/src/tests/conftest.py b/src/tests/conftest.py
new file mode 100644
index 0000000000000000000000000000000000000000..35a6135b0c6da4785b0094bf7a1159ebbda55231
--- /dev/null
+++ b/src/tests/conftest.py
@@ -0,0 +1,204 @@
+# Copyright 2022-2026 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/)
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# This file is an original contribution from Telefonica Innovación Digital S.L.
+
+import sys
+import os
+import sqlite3
+import time
+import base64
+from unittest.mock import MagicMock, patch
+import pytest
+from flask import Flask
+
+# -----------------------------------------------------------------------------
+# Safely mock C-extensions (sysrepo, libyang) if not installed in current environment
+# -----------------------------------------------------------------------------
+if 'sysrepo' not in sys.modules:
+ try:
+ import sysrepo
+ except ImportError:
+ sysrepo_mock = MagicMock()
+ session_mock = MagicMock()
+ session_mock.get_data.return_value = None
+ conn_mock = MagicMock()
+ conn_mock.start_session.return_value = session_mock
+ sysrepo_mock.SysrepoConnection.return_value = conn_mock
+ sys.modules['sysrepo'] = sysrepo_mock
+
+if 'libyang' not in sys.modules:
+ try:
+ import libyang
+ except ImportError:
+ libyang_mock = MagicMock()
+ sys.modules['libyang'] = libyang_mock
+
+
+# -----------------------------------------------------------------------------
+# Shared Pytest Fixtures
+# -----------------------------------------------------------------------------
+
+@pytest.fixture(scope="session")
+def flask_app():
+ """Creates a minimal Flask app for testing with default configuration."""
+ from app import create_app
+ app = create_app()
+ app.config["DUMMY_MODE"] = True
+ app.config.update({
+ "TESTING": True,
+ "SERVER_NAME": "localhost",
+ "API_USERNAME": "admin",
+ "API_PASSWORD": "password",
+ "NRP_ENABLED": False,
+ "PLANNER_ENABLED": False,
+ "PCE_EXTERNAL": False,
+ "DUMMY_MODE": True,
+ "DUMP_TEMPLATES": False,
+ "TFS_L2VPN_SUPPORT": False,
+ "WEBUI_DEPLOY": False,
+ "UPLOAD_TYPE": "WEBUI",
+ "PLANNER_TYPE": "ENERGY",
+ "HRAT_IP": "10.0.0.1",
+ "OPTICAL_PLANNER_IP": "10.0.0.1",
+ "RESTCONF_IP": "10.0.0.1",
+ })
+ return app
+
+
+@pytest.fixture
+def client(flask_app):
+ """Flask test client."""
+ return flask_app.test_client()
+
+
+@pytest.fixture
+def auth_headers(flask_app):
+ """Generates Basic Auth headers matching app config."""
+ username = flask_app.config["API_USERNAME"]
+ password = flask_app.config["API_PASSWORD"]
+ token = base64.b64encode(f"{username}:{password}".encode('utf-8')).decode('utf-8')
+ return {
+ "Authorization": f"Basic {token}",
+ "Content-Type": "application/json"
+ }
+
+
+@pytest.fixture
+def temp_sqlite_db(tmp_path, monkeypatch):
+ """Fixture providing temporary SQLite databases for slice, service, telemetry, and alert stores."""
+ test_db_path = str(tmp_path / "test_slice.db")
+ monkeypatch.setattr("src.database.db.DB_NAME", test_db_path)
+ monkeypatch.setattr("src.database.service_db.DB_NAME", str(tmp_path / "test_service.db"))
+ monkeypatch.setattr("src.database.telemetry_client_db.DB_NAME", str(tmp_path / "test_telemetry.db"))
+ monkeypatch.setattr("src.database.alert_db.DB_NAME", str(tmp_path / "test_alert.db"))
+
+ from src.database.db import init_db as init_slice
+ from src.database.service_db import init_db as init_service
+ from src.database.telemetry_client_db import init_db as init_telemetry
+ from src.database.alert_db import init_db as init_alert
+
+ init_slice()
+ init_service()
+ init_telemetry()
+ init_alert()
+
+ yield test_db_path
+
+
+@pytest.fixture
+def sample_ietf_intent():
+ """Valid sample network slice intent in IETF format."""
+ return {
+ "ietf-network-slice-service:network-slice-services": {
+ "slo-sle-templates": {
+ "slo-sle-template": [
+ {
+ "id": "qos1",
+ "slo-policy": {
+ "metric-bound": [
+ {
+ "metric-type": "one-way-bandwidth",
+ "metric-unit": "kbps",
+ "bound": 100000
+ }
+ ]
+ }
+ }
+ ]
+ },
+ "slice-service": [
+ {
+ "id": "slice-test-01",
+ "service-tags": {"tag-type": [{"tag-type-value": ["L3VPN"]}]},
+ "sdps": {
+ "sdp": [
+ {
+ "id": "sdp-1",
+ "node-id": "A",
+ "sdp-ip-address": "10.0.0.1",
+ "service-match-criteria": {
+ "match-criterion": [
+ {
+ "match-type": [
+ {
+ "type": "vlan",
+ "vlan": [100]
+ }
+ ]
+ }
+ ]
+ },
+ "attachment-circuits": {
+ "attachment-circuit": [
+ {
+ "sdp-peering": {
+ "peer-sap-id": "R1"
+ }
+ }
+ ]
+ }
+ },
+ {
+ "id": "sdp-2",
+ "node-id": "B",
+ "sdp-ip-address": "10.0.0.2",
+ "service-match-criteria": {
+ "match-criterion": [
+ {
+ "match-type": [
+ {
+ "type": "vlan",
+ "vlan": [100]
+ }
+ ]
+ }
+ ]
+ },
+ "attachment-circuits": {
+ "attachment-circuit": [
+ {
+ "sdp-peering": {
+ "peer-sap-id": "R2"
+ }
+ }
+ ]
+ }
+ }
+ ]
+ }
+ }
+ ]
+ }
+ }
diff --git a/src/tests/requests/3gpp_template_UC1PoC2_backhaul.json b/src/tests/requests/6GBlur/3gpp_template_UC1PoC2_backhaul.json
similarity index 100%
rename from src/tests/requests/3gpp_template_UC1PoC2_backhaul.json
rename to src/tests/requests/6GBlur/3gpp_template_UC1PoC2_backhaul.json
diff --git a/src/tests/requests/3gpp_template_UC1PoC2_backhaul_request_1.json b/src/tests/requests/6GBlur/3gpp_template_UC1PoC2_backhaul_request_1.json
similarity index 100%
rename from src/tests/requests/3gpp_template_UC1PoC2_backhaul_request_1.json
rename to src/tests/requests/6GBlur/3gpp_template_UC1PoC2_backhaul_request_1.json
diff --git a/src/tests/requests/3gpp_template_UC1PoC2_backhaul_request_2.json b/src/tests/requests/6GBlur/3gpp_template_UC1PoC2_backhaul_request_2.json
similarity index 100%
rename from src/tests/requests/3gpp_template_UC1PoC2_backhaul_request_2.json
rename to src/tests/requests/6GBlur/3gpp_template_UC1PoC2_backhaul_request_2.json
diff --git a/src/tests/requests/3gpp_template_UC1PoC2_backhaul_request_3.json b/src/tests/requests/6GBlur/3gpp_template_UC1PoC2_backhaul_request_3.json
similarity index 100%
rename from src/tests/requests/3gpp_template_UC1PoC2_backhaul_request_3.json
rename to src/tests/requests/6GBlur/3gpp_template_UC1PoC2_backhaul_request_3.json
diff --git a/src/tests/requests/3gpp_template_UC1PoC2_midhaul.json b/src/tests/requests/6GBlur/3gpp_template_UC1PoC2_midhaul.json
similarity index 100%
rename from src/tests/requests/3gpp_template_UC1PoC2_midhaul.json
rename to src/tests/requests/6GBlur/3gpp_template_UC1PoC2_midhaul.json
diff --git a/src/tests/requests/6GBlur/Demo Jordi/3gpp_template_UC1PoC2_backhaul 1flow_request.json b/src/tests/requests/6GBlur/Demo Jordi/3gpp_template_UC1PoC2_backhaul 1flow_request.json
new file mode 100644
index 0000000000000000000000000000000000000000..d9f5e2ae0c31a7c7fb7bd83abd1b688057b630fa
--- /dev/null
+++ b/src/tests/requests/6GBlur/Demo Jordi/3gpp_template_UC1PoC2_backhaul 1flow_request.json
@@ -0,0 +1,131 @@
+{
+ "NetworkSlice1": {
+ "operationalState": "",
+ "administrativeState": "",
+ "serviceProfileList": [],
+ "networkSliceSubnetRef": "TopSliceSubnet1"
+ },
+ "TopSliceSubnet1": {
+ "operationalState": "",
+ "administrativeState": "",
+ "nsInfo": {},
+ "managedFunctionRef": [],
+ "networkSliceSubnetType": "TOP_SLICESUBNET",
+ "SliceProfileList": [
+ {
+ "sliceProfileId": "TopId",
+ "pLMNInfoList": null,
+ "TopSliceSubnetProfile": {
+ "dLThptPerSliceSubnet": {
+ "GuaThpt": 310,
+ "MaxThpt": 620
+ },
+ "uLThptPerSliceSubnet": {
+ "GuaThpt": 160,
+ "MaxThpt": 320
+ },
+ "dLLatency": 20,
+ "uLLatency": 20
+ }
+ }
+ ],
+ "networkSliceSubnetRef": [
+ "RANSliceSubnet1"
+ ]
+ },
+ "RANSliceSubnet1": {
+ "operationalState": "",
+ "administrativeState": "",
+ "nsInfo": {},
+ "managedFunctionRef": [],
+ "networkSliceSubnetType": "RAN_SLICESUBNET",
+ "SliceProfileList": [
+ {
+ "sliceProfileId": "RANId",
+ "pLMNInfoList": null,
+ "RANSliceSubnetProfile": {
+ "dLThptPerSliceSubnet": {
+ "GuaThpt": 310,
+ "MaxThpt": 620
+ },
+ "uLThptPerSliceSubnet": {
+ "GuaThpt": 160,
+ "MaxThpt": 320
+ },
+ "dLLatency": 20,
+ "uLLatency": 20
+ }
+ }
+ ],
+ "networkSliceSubnetRef": [
+ "BackhaulSliceSubnetN32"
+ ]
+ },
+ "BackhaulSliceSubnetN32": {
+ "operationalState": "",
+ "administrativeState": "",
+ "nsInfo": {},
+ "managedFunctionRef": [],
+ "networkSliceSubnetType": "RAN_SLICESUBNET",
+ "SliceProfileList": [
+ {
+ "sliceProfileId": "BackhaulId",
+ "pLMNInfoList": null,
+ "RANSliceSubnetProfile": {
+ "dLThptPerSliceSubnet": {
+ "GuaThpt": 100000,
+ "MaxThpt": 100000
+ },
+ "uLThptPerSliceSubnet": {
+ "GuaThpt": 100000,
+ "MaxThpt": 100000
+ },
+ "dLLatency": 5,
+ "uLLatency": 5
+ }
+ }
+ ],
+ "EpTransport": [
+ "EpTransport CU-N32",
+ "EpTransport UPF-N32"
+ ]
+ },
+ "EpTransport CU-N32": {
+ "IpAddress": "10.60.11.3",
+ "logicalInterfaceInfo": {
+ "logicalInterfaceType": "VLAN",
+ "logicalInterfaceId": "101"
+ },
+ "NextHopInfo": "4.4.4.4",
+ "qosProfile": "B",
+ "EpApplicationRef": [
+ "EP_N3 CU-N32"
+ ]
+ },
+ "EP_N3 CU-N32": {
+ "localAddress": "10.60.11.3",
+ "remoteAddress": "10.60.10.6",
+ "epTransportRef": [
+ "EpTransport CU-N32"
+ ]
+ },
+ "EpTransport UPF-N32": {
+ "IpAddress": "10.60.10.6",
+ "logicalInterfaceInfo": {
+ "logicalInterfaceType": "VLAN",
+ "logicalInterfaceId": "101"
+ },
+ "NextHopInfo": "5.5.5.5",
+ "qosProfile": "B",
+ "EpApplicationRef": [
+ "EP_N3 UPF-N32"
+ ]
+ },
+ "EP_N3 UPF-N32": {
+ "localAddress": "10.60.10.6",
+ "remoteAddress": "10.60.11.3",
+ "epTransportRef": [
+ "EpTransport UPF-N32"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/src/tests/requests/6GBlur/Demo Jordi/3gpp_template_UC1PoC2_backhaul_2flow_request.json b/src/tests/requests/6GBlur/Demo Jordi/3gpp_template_UC1PoC2_backhaul_2flow_request.json
new file mode 100644
index 0000000000000000000000000000000000000000..2e40c8ece424948c2b0adfdadb1aa86b554f693d
--- /dev/null
+++ b/src/tests/requests/6GBlur/Demo Jordi/3gpp_template_UC1PoC2_backhaul_2flow_request.json
@@ -0,0 +1,199 @@
+{
+ "NetworkSlice1": {
+ "operationalState": "",
+ "administrativeState": "",
+ "serviceProfileList": [],
+ "networkSliceSubnetRef": "TopSliceSubnet1"
+ },
+ "TopSliceSubnet1": {
+ "operationalState": "",
+ "administrativeState": "",
+ "nsInfo": {},
+ "managedFunctionRef": [],
+ "networkSliceSubnetType": "TOP_SLICESUBNET",
+ "SliceProfileList": [
+ {
+ "sliceProfileId": "TopId",
+ "pLMNInfoList": null,
+ "TopSliceSubnetProfile": {
+ "dLThptPerSliceSubnet": {
+ "GuaThpt": 310,
+ "MaxThpt": 620
+ },
+ "uLThptPerSliceSubnet": {
+ "GuaThpt": 160,
+ "MaxThpt": 320
+ },
+ "dLLatency": 20,
+ "uLLatency": 20
+ }
+ }
+ ],
+ "networkSliceSubnetRef": [
+ "RANSliceSubnet1"
+ ]
+ },
+ "RANSliceSubnet1": {
+ "operationalState": "",
+ "administrativeState": "",
+ "nsInfo": {},
+ "managedFunctionRef": [],
+ "networkSliceSubnetType": "RAN_SLICESUBNET",
+ "SliceProfileList": [
+ {
+ "sliceProfileId": "RANId",
+ "pLMNInfoList": null,
+ "RANSliceSubnetProfile": {
+ "dLThptPerSliceSubnet": {
+ "GuaThpt": 310,
+ "MaxThpt": 620
+ },
+ "uLThptPerSliceSubnet": {
+ "GuaThpt": 160,
+ "MaxThpt": 320
+ },
+ "dLLatency": 20,
+ "uLLatency": 20
+ }
+ }
+ ],
+ "networkSliceSubnetRef": [
+ "BackhaulSliceSubnetN2",
+ "BackhaulSliceSubnetN31"
+ ]
+ },
+ "BackhaulSliceSubnetN2": {
+ "operationalState": "",
+ "administrativeState": "",
+ "nsInfo": {},
+ "managedFunctionRef": [],
+ "networkSliceSubnetType": "RAN_SLICESUBNET",
+ "SliceProfileList": [
+ {
+ "sliceProfileId": "BackhaulId",
+ "pLMNInfoList": null,
+ "RANSliceSubnetProfile": {
+ "dLThptPerSliceSubnet": {
+ "GuaThpt": 1000,
+ "MaxThpt": 1000
+ },
+ "uLThptPerSliceSubnet": {
+ "GuaThpt": 1000,
+ "MaxThpt": 1000
+ },
+ "dLLatency": 20,
+ "uLLatency": 20
+ }
+ }
+ ],
+ "EpTransport": [
+ "EpTransport CU-N2",
+ "EpTransport AMF-N2"
+ ]
+ },
+ "BackhaulSliceSubnetN31": {
+ "operationalState": "",
+ "administrativeState": "",
+ "nsInfo": {},
+ "managedFunctionRef": [],
+ "networkSliceSubnetType": "RAN_SLICESUBNET",
+ "SliceProfileList": [
+ {
+ "sliceProfileId": "BackhaulId",
+ "pLMNInfoList": null,
+ "RANSliceSubnetProfile": {
+ "dLThptPerSliceSubnet": {
+ "GuaThpt": 10000,
+ "MaxThpt": 10000
+ },
+ "uLThptPerSliceSubnet": {
+ "GuaThpt": 10000,
+ "MaxThpt": 10000
+ },
+ "dLLatency": 20,
+ "uLLatency": 20
+ }
+ }
+ ],
+ "EpTransport": [
+ "EpTransport CU-N31",
+ "EpTransport UPF-N31"
+ ]
+ },
+ "EpTransport CU-N2": {
+ "IpAddress": "10.60.11.3",
+ "logicalInterfaceInfo": {
+ "logicalInterfaceType": "VLAN",
+ "logicalInterfaceId": "100"
+ },
+ "NextHopInfo": "4.4.4.4",
+ "qosProfile": "A",
+ "EpApplicationRef": [
+ "EP_N2 CU-N2"
+ ]
+ },
+ "EP_N2 CU-N2": {
+ "localAddress": "10.60.11.3",
+ "remoteAddress": "10.60.60.105",
+ "epTransportRef": [
+ "EpTransport CU-N2"
+ ]
+ },
+ "EpTransport AMF-N2": {
+ "IpAddress": "10.60.60.105",
+ "logicalInterfaceInfo": {
+ "logicalInterfaceType": "VLAN",
+ "logicalInterfaceId": "100"
+ },
+ "NextHopInfo": "5.5.5.5",
+ "qosProfile": "A",
+ "EpApplicationRef": [
+ "EP_N2 AMF-N2"
+ ]
+ },
+ "EP_N2 AMF-N2": {
+ "localAddress": "10.60.60.105",
+ "remoteAddress": "10.60.11.3",
+ "epTransportRef": [
+ "EpTransport UPF-N2"
+ ]
+ },
+ "EpTransport CU-N31": {
+ "IpAddress": "10.60.11.3",
+ "logicalInterfaceInfo": {
+ "logicalInterfaceType": "VLAN",
+ "logicalInterfaceId": "102"
+ },
+ "NextHopInfo": "4.4.4.4",
+ "qosProfile": "C",
+ "EpApplicationRef": [
+ "EP_N3 CU-N31"
+ ]
+ },
+ "EP_N3 CU-N31": {
+ "localAddress": "10.60.11.3",
+ "remoteAddress": "10.60.60.106",
+ "epTransportRef": [
+ "EpTransport CU-N31"
+ ]
+ },
+ "EpTransport UPF-N31": {
+ "IpAddress": "10.60.60.106",
+ "logicalInterfaceInfo": {
+ "logicalInterfaceType": "VLAN",
+ "logicalInterfaceId": "102"
+ },
+ "NextHopInfo": "5.5.5.5",
+ "qosProfile": "C",
+ "EpApplicationRef": [
+ "EP_N3 UPF-N31"
+ ]
+ },
+ "EP_N3 UPF-N31": {
+ "localAddress": "10.60.60.106",
+ "remoteAddress": "10.60.11.3",
+ "epTransportRef": [
+ "EpTransport UPF-N31"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/src/tests/requests/3ggpp_template_green.json b/src/tests/requests/6Green/3ggpp_template_green.json
similarity index 93%
rename from src/tests/requests/3ggpp_template_green.json
rename to src/tests/requests/6Green/3ggpp_template_green.json
index 67a1367b093b84c1dd589c803cee55cb130ce232..2330a811fa3cbbdd2eeb989439edba467c9c72db 100644
--- a/src/tests/requests/3ggpp_template_green.json
+++ b/src/tests/requests/6Green/3ggpp_template_green.json
@@ -98,8 +98,8 @@
}
],
"EpTransport": [
- "EpTransport CU-UP1",
- "EpTransport DU3"
+ "EpTransport A",
+ "EpTransport G"
]
},
"BackhaulSliceSubnet1": {
@@ -135,7 +135,7 @@
"EpTransport UPF"
]
},
- "EpTransport CU-UP1": {
+ "EpTransport A": {
"IpAddress": "1.1.1.100",
"logicalInterfaceInfo": {
"logicalInterfaceType": "VLAN",
@@ -144,33 +144,33 @@
"NextHopInfo": "1.1.1.1",
"qosProfile": "5QI100",
"EpApplicationRef": [
- "EP_F1U CU-UP1"
+ "EP_F1U A"
]
},
- "EP_F1U CU-UP1": {
+ "EP_F1U A": {
"localAddress": "100.1.1.100",
"remoteAddress": "200.1.1.100",
"epTransportRef": [
- "EpTransport CU-UP1"
+ "EpTransport A"
]
},
- "EpTransport DU3": {
+ "EpTransport G": {
"IpAddress": "2.2.2.100",
"logicalInterfaceInfo": {
"logicalInterfaceType": "VLAN",
"logicalInterfaceId": "300"
},
- "NextHopInfo": "2.2.2.2",
+ "NextHopInfo": "7.7.7.7",
"qosProfile": "5QI100",
"EpApplicationRef": [
- "EP_F1U DU3"
+ "EP_F1U G"
]
},
- "EP_F1U DU3": {
+ "EP_F1U G": {
"localAddress": "200.1.1.100",
"remoteAddress": "100.1.1.100",
"epTransportRef": [
- "EpTransport DU3"
+ "EpTransport G"
]
}
}
\ No newline at end of file
diff --git a/src/tests/requests/P2MP.json b/src/tests/requests/Allegro-Season-Proteus/P2MP.json
similarity index 100%
rename from src/tests/requests/P2MP.json
rename to src/tests/requests/Allegro-Season-Proteus/P2MP.json
diff --git a/src/tests/requests/create_slice_1.json b/src/tests/requests/Allegro-Season-Proteus/create_slice_1.json
similarity index 100%
rename from src/tests/requests/create_slice_1.json
rename to src/tests/requests/Allegro-Season-Proteus/create_slice_1.json
diff --git a/src/tests/requests/General/l2vpn_test.json b/src/tests/requests/General/l2vpn_test.json
new file mode 100644
index 0000000000000000000000000000000000000000..8db0757007678a3b03c4faeb7105d68ca76c87d2
--- /dev/null
+++ b/src/tests/requests/General/l2vpn_test.json
@@ -0,0 +1,220 @@
+{
+ "ietf-network-slice-service:network-slice-services": {
+ "slo-sle-templates": {
+ "slo-sle-template": [
+ {
+ "id": "gold",
+ "description": "",
+ "slo-policy": {
+ "metric-bound": [
+ {
+ "metric-type": "two-way-bandwidth",
+ "metric-unit": "Mbps",
+ "bound": 100
+ },
+ {
+ "metric-type": "two-way-delay-maximum",
+ "metric-unit": "milliseconds",
+ "bound": 5
+ }
+ ]
+ },
+ "sle-policy": {
+ "security": "",
+ "isolation": "",
+ "path-constraints": {
+ "service-functions": {},
+ "diversity": {
+ "diversity-type": ""
+ }
+ }
+ }
+ },
+ {
+ "id": "silver",
+ "description": "",
+ "slo-policy": {
+ "metric-bound": [
+ {
+ "metric-type": "two-way-bandwidth",
+ "metric-unit": "Mbps",
+ "bound": 10
+ },
+ {
+ "metric-type": "two-way-delay-maximum",
+ "metric-unit": "milliseconds",
+ "bound": 20
+ }
+ ]
+ },
+ "sle-policy": {
+ "security": "",
+ "isolation": "",
+ "path-constraints": {
+ "service-functions": {},
+ "diversity": {
+ "diversity-type": ""
+ }
+ }
+ }
+ },
+ {
+ "id": "bronze",
+ "description": "",
+ "slo-policy": {
+ "metric-bound": [
+ {
+ "metric-type": "two-way-bandwidth",
+ "metric-unit": "Mbps",
+ "bound": 1
+ },
+ {
+ "metric-type": "two-way-delay-maximum",
+ "metric-unit": "milliseconds",
+ "bound": 20
+ }
+ ]
+ },
+ "sle-policy": {
+ "security": "",
+ "isolation": "",
+ "path-constraints": {
+ "service-functions": {},
+ "diversity": {
+ "diversity-type": ""
+ }
+ }
+ }
+ }
+ ]
+ },
+ "slice-service": [
+ {
+ "id": "joint-sdg-transport-slice",
+ "description": "Transport network slice service comprising a path with silver SLOs",
+ "service-tags": {
+ "tag-type": [
+ {
+ "tag-type": "service",
+ "tag-type-value": [
+ "L2"
+ ]
+ }
+ ]
+ },
+ "slo-sle-template": "silver",
+ "status": {},
+ "sdps": {
+ "sdp": [
+ {
+ "id": "SDP1",
+ "geo-location": {},
+ "node-id": "CU",
+ "sdp-ip-address": "",
+ "tp-ref": "",
+ "service-match-criteria": {
+ "match-criterion": [
+ {
+ "index": 1,
+ "match-type": [
+ {
+ "type": "vlan",
+ "vlan": [100]
+ }
+ ],
+ "target-connection-group-id": "CU_DU_1"
+ }
+ ]
+ },
+ "incoming-qos-policy": {},
+ "outgoing-qos-policy": {},
+ "sdp-peering": {
+ "peer-sap-id": "",
+ "protocols": {}
+ },
+ "ac-svc-ref": [],
+ "attachment-circuits": {
+ "attachment-circuit": [
+ {
+ "id": "acSDP1",
+ "ac-node-id": "router-1",
+ "ac-tp-id": "Ethernet10",
+ "ac-ipv4-address": "192.168.251.5",
+ "ac-ipv4-prefix-length": 24,
+ "status": {}
+ }
+ ]
+ },
+ "status": {},
+ "sdp-monitoring": {}
+ },
+ {
+ "id": "SDP2",
+ "geo-location": {},
+ "node-id": "DU",
+ "sdp-ip-address": "",
+ "tp-ref": "",
+ "service-match-criteria": {
+ "match-criterion": [
+ {
+ "index": 1,
+ "match-type": [
+ {
+ "type": "vlan",
+ "vlan": [100]
+ }
+ ],
+ "target-connection-group-id": "CU_DU_1"
+ }
+ ]
+ },
+ "incoming-qos-policy": {},
+ "outgoing-qos-policy": {},
+ "sdp-peering": {
+ "peer-sap-id": "",
+ "protocols": {}
+ },
+ "ac-svc-ref": [],
+ "attachment-circuits": {
+ "attachment-circuit": [
+ {
+ "id": "acSDP2",
+ "ac-node-id": "router-3",
+ "ac-tp-id": "Ethernet10",
+ "ac-ipv4-address": "192.168.252.5",
+ "ac-ipv4-prefix-length": 24,
+ "status": {}
+ }
+ ]
+ },
+ "status": {},
+ "sdp-monitoring": {}
+ }
+ ]
+ },
+ "connection-groups": {
+ "connection-group": [
+ {
+ "id": "CU_DU_1",
+ "connectivity-type": "point-to-point",
+ "connectivity-construct": [
+ {
+ "id": "1",
+ "p2p-sender-sdp": "SDP1",
+ "p2p-receiver-sdp": "SDP2"
+ },
+ {
+ "id": "2",
+ "p2p-sender-sdp": "SDP2",
+ "p2p-receiver-sdp": "SDP1"
+
+ }
+ ],
+ "status": {}
+ }
+ ]
+ }
+ }
+ ]
+ }
+}
diff --git a/src/tests/requests/l3vpn_test.json b/src/tests/requests/General/l3vpn_test.json
similarity index 88%
rename from src/tests/requests/l3vpn_test.json
rename to src/tests/requests/General/l3vpn_test.json
index e3019e59e794d66493817c9d76c75ba7718ec4df..2c4508638912aa7bf9de0d45d755306ab5893ccd 100644
--- a/src/tests/requests/l3vpn_test.json
+++ b/src/tests/requests/General/l3vpn_test.json
@@ -10,26 +10,23 @@
{
"metric-type": "one-way-bandwidth",
"metric-unit": "kbps",
- "bound": 20000000.67
+ "bound": 20000000
},
{
"metric-type": "one-way-delay-maximum",
"metric-unit": "milliseconds",
- "bound": 5.5
+ "bound": 5
}
],
- "availability": 95,
"mtu": 1450
},
"sle-policy": {
"security": "",
"isolation": "",
"path-constraints": {
- "service-functions": "",
+ "service-functions": {},
"diversity": {
- "diversity": {
"diversity-type": ""
- }
}
}
}
@@ -56,7 +53,7 @@
"sdp": [
{
"id": "CU-N2",
- "geo-location": "",
+ "geo-location": {},
"node-id": "CU-N2",
"sdp-ip-address": "10.60.11.3",
"tp-ref": "",
@@ -74,11 +71,11 @@
}
]
},
- "incoming-qos-policy": "",
- "outgoing-qos-policy": "",
+ "incoming-qos-policy": {},
+ "outgoing-qos-policy": {},
"sdp-peering": {
"peer-sap-id": "",
- "protocols": ""
+ "protocols": {}
},
"ac-svc-ref": [],
"attachment-circuits": {
@@ -95,11 +92,11 @@
]
},
"status": {},
- "sdp-monitoring": ""
+ "sdp-monitoring": {}
},
{
"id": "AMF-N2",
- "geo-location": "",
+ "geo-location": {},
"node-id": "AMF-N2",
"sdp-ip-address": "10.60.60.105",
"tp-ref": "",
@@ -117,11 +114,11 @@
}
]
},
- "incoming-qos-policy": "",
- "outgoing-qos-policy": "",
+ "incoming-qos-policy": {},
+ "outgoing-qos-policy": {},
"sdp-peering": {
"peer-sap-id": "",
- "protocols": ""
+ "protocols": {}
},
"ac-svc-ref": [],
"attachment-circuits": {
@@ -138,7 +135,7 @@
]
},
"status": {},
- "sdp-monitoring": ""
+ "sdp-monitoring": {}
}
]
},
@@ -149,7 +146,7 @@
"connectivity-type": "ietf-vpn-common:any-to-any",
"connectivity-construct": [
{
- "id": 1,
+ "id": "1",
"a2a-sdp": [
{
"sdp-id": "01"
diff --git a/src/tests/requests/Hackfest/slice_request 2.json b/src/tests/requests/Hackfest/slice_request 2.json
new file mode 100644
index 0000000000000000000000000000000000000000..62d9088efcfcafb2899182fb0970cc50b4006fc9
--- /dev/null
+++ b/src/tests/requests/Hackfest/slice_request 2.json
@@ -0,0 +1,168 @@
+{
+ "ietf-network-slice-service:network-slice-services": {
+ "slo-sle-templates": {
+ "slo-sle-template": [
+ {
+ "id": "B",
+ "description": "",
+ "slo-policy": {
+ "metric-bound": [
+ {
+ "metric-type": "one-way-bandwidth",
+ "metric-unit": "kbps",
+ "bound": 3000
+ },
+ {
+ "metric-type": "one-way-delay-maximum",
+ "metric-unit": "milliseconds",
+ "bound": 2
+ }
+ ]
+ },
+ "sle-policy": {
+ "security": "",
+ "isolation": "",
+ "path-constraints": {
+ "service-functions": "",
+ "diversity": {
+ "diversity": {
+ "diversity-type": ""
+ }
+ }
+ }
+ }
+ }
+ ]
+ },
+ "slice-service": [
+ {
+ "id": "slice-service-81327140-7361-41b3-aa45-e84a7fb40be9",
+ "description": "Transport network slice mapped with 3GPP slice NetworkSlice1",
+ "service-tags": {
+ "tag-type": [
+ {
+ "tag-type": "service",
+ "tag-type-value": [
+ "L2"
+ ]
+ }
+ ]
+ },
+ "slo-sle-template": "B",
+ "status": {},
+ "sdps": {
+ "sdp": [
+ {
+ "id": "CU-N2",
+ "geo-location": "",
+ "node-id": "CU-N2",
+ "sdp-ip-address": "10.60.11.3",
+ "tp-ref": "",
+ "service-match-criteria": {
+ "match-criterion": [
+ {
+ "index": 1,
+ "match-type": [
+ {
+ "type": "vlan",
+ "vlan": [100]
+ }
+ ],
+ "target-connection-group-id": "CU-N2_AMF-N2"
+ }
+ ]
+ },
+ "incoming-qos-policy": "",
+ "outgoing-qos-policy": "",
+ "sdp-peering": {
+ "peer-sap-id": "",
+ "protocols": ""
+ },
+ "ac-svc-ref": [],
+ "attachment-circuits": {
+ "attachment-circuit": [
+ {
+ "id": "100",
+ "ac-ipv4-address": "10.60.11.3",
+ "ac-ipv4-prefix-length": 0,
+ "sdp-peering": {
+ "peer-sap-id": "1.1.1.1"
+ },
+ "status": {}
+ }
+ ]
+ },
+ "status": {},
+ "sdp-monitoring": ""
+ },
+ {
+ "id": "AMF-N2",
+ "geo-location": "",
+ "node-id": "AMF-N2",
+ "sdp-ip-address": "10.60.60.105",
+ "tp-ref": "",
+ "service-match-criteria": {
+ "match-criterion": [
+ {
+ "index": 1,
+ "match-type": [
+ {
+ "type": "vlan",
+ "vlan": [100]
+ }
+ ],
+ "target-connection-group-id": "CU-N2_AMF-N2"
+ }
+ ]
+ },
+ "incoming-qos-policy": "",
+ "outgoing-qos-policy": "",
+ "sdp-peering": {
+ "peer-sap-id": "",
+ "protocols": ""
+ },
+ "ac-svc-ref": [],
+ "attachment-circuits": {
+ "attachment-circuit": [
+ {
+ "id": "200",
+ "ac-ipv4-address": "10.60.60.105",
+ "ac-ipv4-prefix-length": 0,
+ "sdp-peering": {
+ "peer-sap-id": "3.3.3.3"
+ },
+ "status": {}
+ }
+ ]
+ },
+ "status": {},
+ "sdp-monitoring": ""
+ }
+ ]
+ },
+ "connection-groups": {
+ "connection-group": [
+ {
+ "id": "CU-N2_AMF-N2",
+ "connectivity-type": "ietf-vpn-common:any-to-any",
+ "connectivity-construct": [
+ {
+ "id": 1,
+ "a2a-sdp": [
+ {
+ "sdp-id": "01"
+ },
+ {
+ "sdp-id": "02"
+ }
+ ]
+ }
+ ],
+ "status": {}
+ }
+ ]
+ }
+ }
+ ]
+ }
+ }
\ No newline at end of file
diff --git a/src/tests/requests/slice_request.json b/src/tests/requests/Hackfest/slice_request.json
similarity index 100%
rename from src/tests/requests/slice_request.json
rename to src/tests/requests/Hackfest/slice_request.json
diff --git a/src/tests/requests/Joint SDG lab/request-nsc-test emulated dummy-topo.json b/src/tests/requests/Joint SDG lab/request-nsc-test emulated dummy-topo.json
new file mode 100644
index 0000000000000000000000000000000000000000..fe76c1013b4794f6872a976f2859698971165dbe
--- /dev/null
+++ b/src/tests/requests/Joint SDG lab/request-nsc-test emulated dummy-topo.json
@@ -0,0 +1,220 @@
+{
+ "ietf-network-slice-service:network-slice-services": {
+ "slo-sle-templates": {
+ "slo-sle-template": [
+ {
+ "id": "gold",
+ "description": "",
+ "slo-policy": {
+ "metric-bound": [
+ {
+ "metric-type": "two-way-bandwidth",
+ "metric-unit": "Mbps",
+ "bound": 100
+ },
+ {
+ "metric-type": "two-way-delay-maximum",
+ "metric-unit": "milliseconds",
+ "bound": 5
+ }
+ ]
+ },
+ "sle-policy": {
+ "security": "",
+ "isolation": "",
+ "path-constraints": {
+ "service-functions": {},
+ "diversity": {
+ "diversity-type": ""
+ }
+ }
+ }
+ },
+ {
+ "id": "silver",
+ "description": "",
+ "slo-policy": {
+ "metric-bound": [
+ {
+ "metric-type": "two-way-bandwidth",
+ "metric-unit": "Mbps",
+ "bound": 10
+ },
+ {
+ "metric-type": "two-way-delay-maximum",
+ "metric-unit": "milliseconds",
+ "bound": 20
+ }
+ ]
+ },
+ "sle-policy": {
+ "security": "",
+ "isolation": "",
+ "path-constraints": {
+ "service-functions": {},
+ "diversity": {
+ "diversity-type": ""
+ }
+ }
+ }
+ },
+ {
+ "id": "bronze",
+ "description": "",
+ "slo-policy": {
+ "metric-bound": [
+ {
+ "metric-type": "two-way-bandwidth",
+ "metric-unit": "Mbps",
+ "bound": 1
+ },
+ {
+ "metric-type": "two-way-delay-maximum",
+ "metric-unit": "milliseconds",
+ "bound": 20
+ }
+ ]
+ },
+ "sle-policy": {
+ "security": "",
+ "isolation": "",
+ "path-constraints": {
+ "service-functions": {},
+ "diversity": {
+ "diversity-type": ""
+ }
+ }
+ }
+ }
+ ]
+ },
+ "slice-service": [
+ {
+ "id": "joint-sdg-transport-slice",
+ "description": "Transport network slice service comprising a path with silver SLOs",
+ "service-tags": {
+ "tag-type": [
+ {
+ "tag-type": "service",
+ "tag-type-value": [
+ "L3"
+ ]
+ }
+ ]
+ },
+ "slo-sle-template": "silver",
+ "status": {},
+ "sdps": {
+ "sdp": [
+ {
+ "id": "SDP1",
+ "geo-location": {},
+ "node-id": "CU",
+ "sdp-ip-address": "",
+ "tp-ref": "",
+ "service-match-criteria": {
+ "match-criterion": [
+ {
+ "index": 1,
+ "match-type": [
+ {
+ "type": "vlan",
+ "vlan": [100]
+ }
+ ],
+ "target-connection-group-id": "CU_DU_1"
+ }
+ ]
+ },
+ "incoming-qos-policy": {},
+ "outgoing-qos-policy": {},
+ "sdp-peering": {
+ "peer-sap-id": "",
+ "protocols": {}
+ },
+ "ac-svc-ref": [],
+ "attachment-circuits": {
+ "attachment-circuit": [
+ {
+ "id": "acSDP1",
+ "ac-node-id": "128.32.33.5",
+ "ac-tp-id": "500",
+ "ac-ipv4-address": "128.32.33.5",
+ "ac-ipv4-prefix-length": 24,
+ "status": {}
+ }
+ ]
+ },
+ "status": {},
+ "sdp-monitoring": {}
+ },
+ {
+ "id": "SDP2",
+ "geo-location": {},
+ "node-id": "DU",
+ "sdp-ip-address": "",
+ "tp-ref": "",
+ "service-match-criteria": {
+ "match-criterion": [
+ {
+ "index": 1,
+ "match-type": [
+ {
+ "type": "vlan",
+ "vlan": [100]
+ }
+ ],
+ "target-connection-group-id": "CU_DU_1"
+ }
+ ]
+ },
+ "incoming-qos-policy": {},
+ "outgoing-qos-policy": {},
+ "sdp-peering": {
+ "peer-sap-id": "",
+ "protocols": {}
+ },
+ "ac-svc-ref": [],
+ "attachment-circuits": {
+ "attachment-circuit": [
+ {
+ "id": "acSDP2",
+ "ac-node-id": "172.10.33.5",
+ "ac-tp-id": "500",
+ "ac-ipv4-address": "172.10.33.5",
+ "ac-ipv4-prefix-length": 24,
+ "status": {}
+ }
+ ]
+ },
+ "status": {},
+ "sdp-monitoring": {}
+ }
+ ]
+ },
+ "connection-groups": {
+ "connection-group": [
+ {
+ "id": "CU_DU_1",
+ "connectivity-type": "point-to-point",
+ "connectivity-construct": [
+ {
+ "id": "1",
+ "p2p-sender-sdp": "SDP1",
+ "p2p-receiver-sdp": "SDP2"
+ },
+ {
+ "id": "2",
+ "p2p-sender-sdp": "SDP2",
+ "p2p-receiver-sdp": "SDP1"
+
+ }
+ ],
+ "status": {}
+ }
+ ]
+ }
+ }
+ ]
+ }
+}
diff --git a/src/tests/requests/Joint SDG lab/request-nsc-test eucnc24.json b/src/tests/requests/Joint SDG lab/request-nsc-test eucnc24.json
new file mode 100644
index 0000000000000000000000000000000000000000..b6a9cfb020363a291a64d2e06428a9b4e69d7f24
--- /dev/null
+++ b/src/tests/requests/Joint SDG lab/request-nsc-test eucnc24.json
@@ -0,0 +1,220 @@
+{
+ "ietf-network-slice-service:network-slice-services": {
+ "slo-sle-templates": {
+ "slo-sle-template": [
+ {
+ "id": "gold",
+ "description": "",
+ "slo-policy": {
+ "metric-bound": [
+ {
+ "metric-type": "two-way-bandwidth",
+ "metric-unit": "Mbps",
+ "bound": 100
+ },
+ {
+ "metric-type": "two-way-delay-maximum",
+ "metric-unit": "milliseconds",
+ "bound": 5
+ }
+ ]
+ },
+ "sle-policy": {
+ "security": "",
+ "isolation": "",
+ "path-constraints": {
+ "service-functions": {},
+ "diversity": {
+ "diversity-type": ""
+ }
+ }
+ }
+ },
+ {
+ "id": "silver",
+ "description": "",
+ "slo-policy": {
+ "metric-bound": [
+ {
+ "metric-type": "two-way-bandwidth",
+ "metric-unit": "Mbps",
+ "bound": 10
+ },
+ {
+ "metric-type": "two-way-delay-maximum",
+ "metric-unit": "milliseconds",
+ "bound": 20
+ }
+ ]
+ },
+ "sle-policy": {
+ "security": "",
+ "isolation": "",
+ "path-constraints": {
+ "service-functions": {},
+ "diversity": {
+ "diversity-type": ""
+ }
+ }
+ }
+ },
+ {
+ "id": "bronze",
+ "description": "",
+ "slo-policy": {
+ "metric-bound": [
+ {
+ "metric-type": "two-way-bandwidth",
+ "metric-unit": "Mbps",
+ "bound": 1
+ },
+ {
+ "metric-type": "two-way-delay-maximum",
+ "metric-unit": "milliseconds",
+ "bound": 20
+ }
+ ]
+ },
+ "sle-policy": {
+ "security": "",
+ "isolation": "",
+ "path-constraints": {
+ "service-functions": {},
+ "diversity": {
+ "diversity-type": ""
+ }
+ }
+ }
+ }
+ ]
+ },
+ "slice-service": [
+ {
+ "id": "joint-sdg-transport-slice",
+ "description": "Transport network slice service comprising a path with silver SLOs",
+ "service-tags": {
+ "tag-type": [
+ {
+ "tag-type": "service",
+ "tag-type-value": [
+ "L3"
+ ]
+ }
+ ]
+ },
+ "slo-sle-template": "silver",
+ "status": {},
+ "sdps": {
+ "sdp": [
+ {
+ "id": "SDP1",
+ "geo-location": {},
+ "node-id": "DC1",
+ "sdp-ip-address": "",
+ "tp-ref": "",
+ "service-match-criteria": {
+ "match-criterion": [
+ {
+ "index": 1,
+ "match-type": [
+ {
+ "type": "any"
+ }
+ ],
+ "target-connection-group-id": "DC1_DC2_1"
+ }
+ ]
+ },
+ "incoming-qos-policy": {},
+ "outgoing-qos-policy": {},
+ "sdp-peering": {
+ "peer-sap-id": "",
+ "protocols": {}
+ },
+ "ac-svc-ref": [],
+ "attachment-circuits": {
+ "attachment-circuit": [
+ {
+ "id": "acSDP1",
+ "description": "AC connected to DC1",
+ "ac-node-id": "dc1",
+ "ac-tp-id": "eth1",
+ "ac-ipv4-address": "172.16.1.10",
+ "ac-ipv4-prefix-length": 24,
+ "status": {}
+ }
+ ]
+ },
+ "status": {},
+ "sdp-monitoring": {}
+ },
+ {
+ "id": "SDP2",
+ "geo-location": {},
+ "node-id": "DC2",
+ "sdp-ip-address": "",
+ "tp-ref": "",
+ "service-match-criteria": {
+ "match-criterion": [
+ {
+ "index": 1,
+ "match-type": [
+ {
+ "type": "any"
+ }
+ ],
+ "target-connection-group-id": "DC1_DC2_1"
+ }
+ ]
+ },
+ "incoming-qos-policy": {},
+ "outgoing-qos-policy": {},
+ "sdp-peering": {
+ "peer-sap-id": "",
+ "protocols": {}
+ },
+ "ac-svc-ref": [],
+ "attachment-circuits": {
+ "attachment-circuit": [
+ {
+ "id": "acSDP2",
+ "description": "AC connected to DC2",
+ "ac-node-id": "dc2",
+ "ac-tp-id": "eth1",
+ "ac-ipv4-address": "172.16.2.10",
+ "ac-ipv4-prefix-length": 24,
+ "status": {}
+ }
+ ]
+ },
+ "status": {},
+ "sdp-monitoring": {}
+ }
+ ]
+ },
+ "connection-groups": {
+ "connection-group": [
+ {
+ "id": "DC1_DC2_1",
+ "connectivity-type": "point-to-point",
+ "connectivity-construct": [
+ {
+ "id": "1",
+ "p2p-sender-sdp": "SDP1",
+ "p2p-receiver-sdp": "SDP2"
+ },
+ {
+ "id": "2",
+ "p2p-sender-sdp": "SDP2",
+ "p2p-receiver-sdp": "SDP1"
+
+ }
+ ],
+ "status": {}
+ }
+ ]
+ }
+ }
+ ]
+ }
+}
diff --git a/src/tests/requests/Joint SDG lab/request-nsc.json b/src/tests/requests/Joint SDG lab/request-nsc.json
new file mode 100644
index 0000000000000000000000000000000000000000..e12307af9359bd657b717ce9abfd4b73f5c645f9
--- /dev/null
+++ b/src/tests/requests/Joint SDG lab/request-nsc.json
@@ -0,0 +1,220 @@
+{
+ "ietf-network-slice-service:network-slice-services": {
+ "slo-sle-templates": {
+ "slo-sle-template": [
+ {
+ "id": "gold",
+ "description": "",
+ "slo-policy": {
+ "metric-bound": [
+ {
+ "metric-type": "two-way-bandwidth",
+ "metric-unit": "Mbps",
+ "bound": 100
+ },
+ {
+ "metric-type": "two-way-delay-maximum",
+ "metric-unit": "milliseconds",
+ "bound": 5
+ }
+ ]
+ },
+ "sle-policy": {
+ "security": "",
+ "isolation": "",
+ "path-constraints": {
+ "service-functions": {},
+ "diversity": {
+ "diversity-type": ""
+ }
+ }
+ }
+ },
+ {
+ "id": "silver",
+ "description": "",
+ "slo-policy": {
+ "metric-bound": [
+ {
+ "metric-type": "two-way-bandwidth",
+ "metric-unit": "Mbps",
+ "bound": 10
+ },
+ {
+ "metric-type": "two-way-delay-maximum",
+ "metric-unit": "milliseconds",
+ "bound": 20
+ }
+ ]
+ },
+ "sle-policy": {
+ "security": "",
+ "isolation": "",
+ "path-constraints": {
+ "service-functions": {},
+ "diversity": {
+ "diversity-type": ""
+ }
+ }
+ }
+ },
+ {
+ "id": "bronze",
+ "description": "",
+ "slo-policy": {
+ "metric-bound": [
+ {
+ "metric-type": "two-way-bandwidth",
+ "metric-unit": "Mbps",
+ "bound": 1
+ },
+ {
+ "metric-type": "two-way-delay-maximum",
+ "metric-unit": "milliseconds",
+ "bound": 20
+ }
+ ]
+ },
+ "sle-policy": {
+ "security": "",
+ "isolation": "",
+ "path-constraints": {
+ "service-functions": {},
+ "diversity": {
+ "diversity-type": ""
+ }
+ }
+ }
+ }
+ ]
+ },
+ "slice-service": [
+ {
+ "id": "joint-sdg-transport-slice",
+ "description": "Transport network slice service comprising a path with silver SLOs",
+ "service-tags": {
+ "tag-type": [
+ {
+ "tag-type": "service",
+ "tag-type-value": [
+ "L3"
+ ]
+ }
+ ]
+ },
+ "slo-sle-template": "silver",
+ "status": {},
+ "sdps": {
+ "sdp": [
+ {
+ "id": "SDP1",
+ "geo-location": {},
+ "node-id": "CU",
+ "sdp-ip-address": "",
+ "tp-ref": "",
+ "service-match-criteria": {
+ "match-criterion": [
+ {
+ "index": 1,
+ "match-type": [
+ {
+ "type": "vlan",
+ "vlan": [100]
+ }
+ ],
+ "target-connection-group-id": "CU_DU_1"
+ }
+ ]
+ },
+ "incoming-qos-policy": {},
+ "outgoing-qos-policy": {},
+ "sdp-peering": {
+ "peer-sap-id": "",
+ "protocols": {}
+ },
+ "ac-svc-ref": [],
+ "attachment-circuits": {
+ "attachment-circuit": [
+ {
+ "id": "acSDP1",
+ "ac-node-id": "router-1",
+ "ac-tp-id": "Ethernet10",
+ "ac-ipv4-address": "192.168.251.5",
+ "ac-ipv4-prefix-length": 24,
+ "status": {}
+ }
+ ]
+ },
+ "status": {},
+ "sdp-monitoring": {}
+ },
+ {
+ "id": "SDP2",
+ "geo-location": {},
+ "node-id": "DU",
+ "sdp-ip-address": "",
+ "tp-ref": "",
+ "service-match-criteria": {
+ "match-criterion": [
+ {
+ "index": 1,
+ "match-type": [
+ {
+ "type": "vlan",
+ "vlan": [100]
+ }
+ ],
+ "target-connection-group-id": "CU_DU_1"
+ }
+ ]
+ },
+ "incoming-qos-policy": {},
+ "outgoing-qos-policy": {},
+ "sdp-peering": {
+ "peer-sap-id": "",
+ "protocols": {}
+ },
+ "ac-svc-ref": [],
+ "attachment-circuits": {
+ "attachment-circuit": [
+ {
+ "id": "acSDP2",
+ "ac-node-id": "router-3",
+ "ac-tp-id": "Ethernet10",
+ "ac-ipv4-address": "192.168.252.5",
+ "ac-ipv4-prefix-length": 24,
+ "status": {}
+ }
+ ]
+ },
+ "status": {},
+ "sdp-monitoring": {}
+ }
+ ]
+ },
+ "connection-groups": {
+ "connection-group": [
+ {
+ "id": "CU_DU_1",
+ "connectivity-type": "point-to-point",
+ "connectivity-construct": [
+ {
+ "id": "1",
+ "p2p-sender-sdp": "SDP1",
+ "p2p-receiver-sdp": "SDP2"
+ },
+ {
+ "id": "2",
+ "p2p-sender-sdp": "SDP2",
+ "p2p-receiver-sdp": "SDP1"
+
+ }
+ ],
+ "status": {}
+ }
+ ]
+ }
+ }
+ ]
+ }
+}
diff --git a/src/tests/requests/SIMAP/test_simap.json b/src/tests/requests/SIMAP/test_simap.json
new file mode 100644
index 0000000000000000000000000000000000000000..ef9085614571c0f6888410f9fa9c9567391d5969
--- /dev/null
+++ b/src/tests/requests/SIMAP/test_simap.json
@@ -0,0 +1,296 @@
+{
+ "ietf-network-slice-service:network-slice-services": {
+ "slo-sle-templates": {
+ "slo-sle-template": [
+ {
+ "id": "gold",
+ "description": "",
+ "slo-policy": {
+ "metric-bound": [
+ {
+ "metric-type": "two-way-bandwidth",
+ "metric-unit": "Mbps",
+ "bound": 100
+ },
+ {
+ "metric-type": "two-way-delay-maximum",
+ "metric-unit": "milliseconds",
+ "bound": 5
+ }
+ ]
+ },
+ "sle-policy": {
+ "security": "",
+ "isolation": "",
+ "path-constraints": {
+ "service-functions": {},
+ "diversity": {
+ "diversity-type": ""
+ }
+ }
+ }
+ },
+ {
+ "id": "silver",
+ "description": "",
+ "slo-policy": {
+ "metric-bound": [
+ {
+ "metric-type": "two-way-bandwidth",
+ "metric-unit": "Mbps",
+ "bound": 10
+ },
+ {
+ "metric-type": "two-way-delay-maximum",
+ "metric-unit": "milliseconds",
+ "bound": 20
+ }
+ ]
+ },
+ "sle-policy": {
+ "security": "",
+ "isolation": "",
+ "path-constraints": {
+ "service-functions": {},
+ "diversity": {
+ "diversity-type": ""
+ }
+ }
+ }
+ },
+ {
+ "id": "bronze",
+ "description": "",
+ "slo-policy": {
+ "metric-bound": [
+ {
+ "metric-type": "two-way-bandwidth",
+ "metric-unit": "Mbps",
+ "bound": 1
+ },
+ {
+ "metric-type": "two-way-delay-maximum",
+ "metric-unit": "milliseconds",
+ "bound": 20
+ }
+ ]
+ },
+ "sle-policy": {
+ "security": "",
+ "isolation": "",
+ "path-constraints": {
+ "service-functions": {},
+ "diversity": {
+ "diversity-type": ""
+ }
+ }
+ }
+ }
+ ]
+ },
+ "slice-service": [
+ {
+ "id": "simap-slice",
+ "description": "Transport network slice service comprising three paths with three different slo templates. SDPs are matched by DSCP of incoming traffic",
+ "service-tags": {
+ "tag-type": [
+ {
+ "tag-type": "service",
+ "tag-type-value": [
+ "L3"
+ ]
+ }
+ ]
+ },
+ "slo-sle-template": "bronze",
+ "status": {},
+ "sdps": {
+ "sdp": [
+ {
+ "id": "2.2.2.2",
+ "geo-location": {},
+ "node-id": "CU",
+ "sdp-ip-address": "",
+ "tp-ref": "",
+ "service-match-criteria": {
+ "match-criterion": [
+ {
+ "index": 1,
+ "match-type": [
+ {
+ "type": "dscp",
+ "dscp": [51]
+ }
+ ],
+ "target-connection-group-id": "CU_DU_1"
+ },
+ {
+ "index": 2,
+ "match-type": [
+ {
+ "type": "dscp",
+ "dscp": [52]
+ }
+ ],
+ "target-connection-group-id": "CU_DU_2"
+ },
+ {
+ "index": 3,
+ "match-type": [
+ {
+ "type": "any"
+ }
+ ],
+ "target-connection-group-id": "CU_DU_3"
+ }
+ ]
+ },
+ "incoming-qos-policy": {},
+ "outgoing-qos-policy": {},
+ "sdp-peering": {
+ "peer-sap-id": "",
+ "protocols": {}
+ },
+ "ac-svc-ref": [],
+ "attachment-circuits": {
+ "attachment-circuit": [
+ {
+ "id": "acSDP1",
+ "ac-node-id": "PE-A",
+ "ac-tp-id": "eth0",
+ "ac-ipv4-address": "2.2.2.2",
+ "ac-ipv4-prefix-length": 24,
+ "status": {}
+ }
+ ]
+ },
+ "status": {},
+ "sdp-monitoring": {}
+ },
+ {
+ "id": "5.5.5.5",
+ "geo-location": {},
+ "node-id": "DU",
+ "sdp-ip-address": "",
+ "tp-ref": "",
+ "service-match-criteria": {
+ "match-criterion": [
+ {
+ "index": 1,
+ "match-type": [
+ {
+ "type": "dscp",
+ "dscp": [51]
+ }
+ ],
+ "target-connection-group-id": "CU_DU_1"
+ },
+ {
+ "index": 2,
+ "match-type": [
+ {
+ "type": "dscp",
+ "dscp": [52]
+ }
+ ],
+ "target-connection-group-id": "CU_DU_2"
+ },
+ {
+ "index": 3,
+ "match-type": [
+ {
+ "type": "any"
+ }
+ ],
+ "target-connection-group-id": "CU_DU_3"
+ }
+ ]
+ },
+ "incoming-qos-policy": {},
+ "outgoing-qos-policy": {},
+ "sdp-peering": {
+ "peer-sap-id": "",
+ "protocols": {}
+ },
+ "ac-svc-ref": [],
+ "attachment-circuits": {
+ "attachment-circuit": [
+ {
+ "id": "acSDP2",
+ "ac-node-id": "PE-B",
+ "ac-tp-id": "eth0",
+ "ac-ipv4-address": "5.5.5.5",
+ "ac-ipv4-prefix-length": 24,
+ "status": {}
+ }
+ ]
+ },
+ "status": {},
+ "sdp-monitoring": {}
+ }
+ ]
+ },
+ "connection-groups": {
+ "connection-group": [
+ {
+ "id": "CU_DU_1",
+ "connectivity-type": "point-to-point",
+ "slo-sle-template": "silver",
+ "connectivity-construct": [
+ {
+ "id": "1",
+ "p2p-sender-sdp": "2.2.2.2",
+ "p2p-receiver-sdp": "5.5.5.5"
+ },
+ {
+ "id": "2",
+ "p2p-sender-sdp": "5.5.5.5",
+ "p2p-receiver-sdp": "2.2.2.2"
+
+ }
+ ],
+ "status": {}
+ },
+ {
+ "id": "CU_DU_2",
+ "connectivity-type": "point-to-point",
+ "slo-sle-template": "gold",
+ "connectivity-construct": [
+ {
+ "id": "3",
+ "p2p-sender-sdp": "2.2.2.2",
+ "p2p-receiver-sdp": "5.5.5.5"
+ },
+ {
+ "id": "4",
+ "p2p-sender-sdp": "5.5.5.5",
+ "p2p-receiver-sdp": "2.2.2.2"
+
+ }
+ ],
+ "status": {}
+ },
+ {
+ "id": "CU_DU_3",
+ "connectivity-type": "point-to-point",
+ "connectivity-construct": [
+ {
+ "id": "5",
+ "p2p-sender-sdp": "2.2.2.2",
+ "p2p-receiver-sdp": "5.5.5.5"
+ },
+ {
+ "id": "6",
+ "p2p-sender-sdp": "5.5.5.5",
+ "p2p-receiver-sdp": "2.2.2.2"
+
+ }
+ ],
+ "status": {}
+ }
+ ]
+ }
+ }
+ ]
+ }
+}
diff --git a/src/tests/requests/SIMAP/test_simap_2.json b/src/tests/requests/SIMAP/test_simap_2.json
new file mode 100644
index 0000000000000000000000000000000000000000..723055cf8a04553f8e6c5a928eb4e2ec0422c6dc
--- /dev/null
+++ b/src/tests/requests/SIMAP/test_simap_2.json
@@ -0,0 +1,296 @@
+{
+ "ietf-network-slice-service:network-slice-services": {
+ "slo-sle-templates": {
+ "slo-sle-template": [
+ {
+ "id": "gold",
+ "description": "",
+ "slo-policy": {
+ "metric-bound": [
+ {
+ "metric-type": "two-way-bandwidth",
+ "metric-unit": "Mbps",
+ "bound": 100
+ },
+ {
+ "metric-type": "two-way-delay-maximum",
+ "metric-unit": "milliseconds",
+ "bound": 5
+ }
+ ]
+ },
+ "sle-policy": {
+ "security": "",
+ "isolation": "",
+ "path-constraints": {
+ "service-functions": {},
+ "diversity": {
+ "diversity-type": ""
+ }
+ }
+ }
+ },
+ {
+ "id": "silver",
+ "description": "",
+ "slo-policy": {
+ "metric-bound": [
+ {
+ "metric-type": "two-way-bandwidth",
+ "metric-unit": "Mbps",
+ "bound": 10
+ },
+ {
+ "metric-type": "two-way-delay-maximum",
+ "metric-unit": "milliseconds",
+ "bound": 20
+ }
+ ]
+ },
+ "sle-policy": {
+ "security": "",
+ "isolation": "",
+ "path-constraints": {
+ "service-functions": {},
+ "diversity": {
+ "diversity-type": ""
+ }
+ }
+ }
+ },
+ {
+ "id": "bronze",
+ "description": "",
+ "slo-policy": {
+ "metric-bound": [
+ {
+ "metric-type": "two-way-bandwidth",
+ "metric-unit": "Mbps",
+ "bound": 1
+ },
+ {
+ "metric-type": "two-way-delay-maximum",
+ "metric-unit": "milliseconds",
+ "bound": 20
+ }
+ ]
+ },
+ "sle-policy": {
+ "security": "",
+ "isolation": "",
+ "path-constraints": {
+ "service-functions": {},
+ "diversity": {
+ "diversity-type": ""
+ }
+ }
+ }
+ }
+ ]
+ },
+ "slice-service": [
+ {
+ "id": "simap-slice-2",
+ "description": "Transport network slice service comprising three paths with three different slo templates. SDPs are matched by DSCP of incoming traffic",
+ "service-tags": {
+ "tag-type": [
+ {
+ "tag-type": "service",
+ "tag-type-value": [
+ "L3"
+ ]
+ }
+ ]
+ },
+ "slo-sle-template": "silver",
+ "status": {},
+ "sdps": {
+ "sdp": [
+ {
+ "id": "1.1.1.1",
+ "geo-location": {},
+ "node-id": "CU",
+ "sdp-ip-address": "",
+ "tp-ref": "",
+ "service-match-criteria": {
+ "match-criterion": [
+ {
+ "index": 1,
+ "match-type": [
+ {
+ "type": "dscp",
+ "dscp": [51]
+ }
+ ],
+ "target-connection-group-id": "CU_DU_1"
+ },
+ {
+ "index": 2,
+ "match-type": [
+ {
+ "type": "dscp",
+ "dscp": [52]
+ }
+ ],
+ "target-connection-group-id": "CU_DU_2"
+ },
+ {
+ "index": 3,
+ "match-type": [
+ {
+ "type": "any"
+ }
+ ],
+ "target-connection-group-id": "CU_DU_3"
+ }
+ ]
+ },
+ "incoming-qos-policy": {},
+ "outgoing-qos-policy": {},
+ "sdp-peering": {
+ "peer-sap-id": "",
+ "protocols": {}
+ },
+ "ac-svc-ref": [],
+ "attachment-circuits": {
+ "attachment-circuit": [
+ {
+ "id": "acSDP1",
+ "ac-node-id": "PE-A",
+ "ac-tp-id": "eth0",
+ "ac-ipv4-address": "1.1.1.1",
+ "ac-ipv4-prefix-length": 24,
+ "status": {}
+ }
+ ]
+ },
+ "status": {},
+ "sdp-monitoring": {}
+ },
+ {
+ "id": "3.3.3.3",
+ "geo-location": {},
+ "node-id": "DU",
+ "sdp-ip-address": "",
+ "tp-ref": "",
+ "service-match-criteria": {
+ "match-criterion": [
+ {
+ "index": 1,
+ "match-type": [
+ {
+ "type": "dscp",
+ "dscp": [51]
+ }
+ ],
+ "target-connection-group-id": "CU_DU_1"
+ },
+ {
+ "index": 2,
+ "match-type": [
+ {
+ "type": "dscp",
+ "dscp": [52]
+ }
+ ],
+ "target-connection-group-id": "CU_DU_2"
+ },
+ {
+ "index": 3,
+ "match-type": [
+ {
+ "type": "any"
+ }
+ ],
+ "target-connection-group-id": "CU_DU_3"
+ }
+ ]
+ },
+ "incoming-qos-policy": {},
+ "outgoing-qos-policy": {},
+ "sdp-peering": {
+ "peer-sap-id": "",
+ "protocols": {}
+ },
+ "ac-svc-ref": [],
+ "attachment-circuits": {
+ "attachment-circuit": [
+ {
+ "id": "acSDP2",
+ "ac-node-id": "PE-B",
+ "ac-tp-id": "eth0",
+ "ac-ipv4-address": "3.3.3.3",
+ "ac-ipv4-prefix-length": 24,
+ "status": {}
+ }
+ ]
+ },
+ "status": {},
+ "sdp-monitoring": {}
+ }
+ ]
+ },
+ "connection-groups": {
+ "connection-group": [
+ {
+ "id": "CU_DU_1",
+ "connectivity-type": "point-to-point",
+ "slo-sle-template": "silver",
+ "connectivity-construct": [
+ {
+ "id": "1",
+ "p2p-sender-sdp": "1.1.1.1",
+ "p2p-receiver-sdp": "3.3.3.3"
+ },
+ {
+ "id": "2",
+ "p2p-sender-sdp": "3.3.3.3",
+ "p2p-receiver-sdp": "1.1.1.1"
+
+ }
+ ],
+ "status": {}
+ },
+ {
+ "id": "CU_DU_2",
+ "connectivity-type": "point-to-point",
+ "slo-sle-template": "gold",
+ "connectivity-construct": [
+ {
+ "id": "3",
+ "p2p-sender-sdp": "1.1.1.1",
+ "p2p-receiver-sdp": "3.3.3.3"
+ },
+ {
+ "id": "4",
+ "p2p-sender-sdp": "3.3.3.3",
+ "p2p-receiver-sdp": "1.1.1.1"
+
+ }
+ ],
+ "status": {}
+ },
+ {
+ "id": "CU_DU_3",
+ "connectivity-type": "point-to-point",
+ "connectivity-construct": [
+ {
+ "id": "5",
+ "p2p-sender-sdp": "1.1.1.1",
+ "p2p-receiver-sdp": "3.3.3.3"
+ },
+ {
+ "id": "6",
+ "p2p-sender-sdp": "3.3.3.3",
+ "p2p-receiver-sdp": "1.1.1.1"
+
+ }
+ ],
+ "status": {}
+ }
+ ]
+ }
+ }
+ ]
+ }
+}
diff --git a/src/tests/requests/ietf_template_timing.json b/src/tests/requests/Timing/ietf_template_timing.json
similarity index 100%
rename from src/tests/requests/ietf_template_timing.json
rename to src/tests/requests/Timing/ietf_template_timing.json
diff --git a/src/tests/requests/frr_request.json b/src/tests/requests/Unity-6G/frr_request.json
similarity index 100%
rename from src/tests/requests/frr_request.json
rename to src/tests/requests/Unity-6G/frr_request.json
diff --git a/src/tests/test_api.py b/src/tests/test_api.py
index 61d5151408091d3aabcdde7c889242b6243aaab0..e1234dc18d89eaab60ce7763984284e0a103ab41 100644
--- a/src/tests/test_api.py
+++ b/src/tests/test_api.py
@@ -1,323 +1,1387 @@
-# Copyright 2022-2026 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/)
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-
-# This file is an original contribution from Telefonica Innovación Digital S.L.
-
-import json
-import pytest
-import os
-from unittest.mock import patch, Mock, MagicMock
-from pathlib import Path
-from dotenv import load_dotenv
-import sqlite3
-import time
-from flask import Flask
-from src.main import NSController
-from src.api.main import Api
-
-
-# Load environment variables
-load_dotenv()
-
-@pytest.fixture(scope="session")
-def flask_app():
- """Creates a minimal Flask app for tests."""
- app = Flask(__name__)
- app.config.update({
- "TESTING": True,
- "SERVER_NAME": "localhost",
- 'NRP_ENABLED': os.getenv('NRP_ENABLED', 'False').lower() == 'true',
- 'PLANNER_ENABLED': os.getenv('PLANNER_ENABLED', 'False').lower() == 'true',
- 'PCE_EXTERNAL': os.getenv('PCE_EXTERNAL', 'False').lower() == 'true',
- 'DUMMY_MODE': os.getenv('DUMMY_MODE', 'True').lower() == 'true',
- 'DUMP_TEMPLATES': os.getenv('DUMP_TEMPLATES', 'False').lower() == 'true',
- 'TFS_L2VPN_SUPPORT': os.getenv('TFS_L2VPN_SUPPORT', 'False').lower() == 'true',
- 'WEBUI_DEPLOY': os.getenv('WEBUI_DEPLOY', 'True').lower() == 'true',
- 'UPLOAD_TYPE': os.getenv('UPLOAD_TYPE', 'WEBUI'),
- 'PLANNER_TYPE': os.getenv('PLANNER_TYPE', 'ENERGY'),
- 'HRAT_IP' : os.getenv('HRAT_IP', '10.0.0.1'),
- 'OPTICAL_PLANNER_IP' : os.getenv('OPTICAL_PLANNER_IP', '10.0.0.1')
- })
- return app
-
-
-@pytest.fixture(autouse=True)
-def push_flask_context(flask_app):
- """Automatically pushes a Flask context for each test."""
- with flask_app.app_context():
- yield
-
-@pytest.fixture
-def temp_db(tmp_path):
- """Fixture to create and cleanup a test database using SQLite instead of JSON."""
- test_db_name = str(tmp_path / "test_slice.db")
-
- # Create database with proper schema
- conn = sqlite3.connect(test_db_name)
- cursor = conn.cursor()
- cursor.execute("""
- CREATE TABLE IF NOT EXISTS slice (
- slice_id TEXT PRIMARY KEY,
- intent TEXT NOT NULL,
- controller TEXT NOT NULL
- )
- """)
- conn.commit()
- conn.close()
-
- yield test_db_name
-
- # Cleanup - properly close connections and remove file
- try:
- time.sleep(0.1)
- if os.path.exists(test_db_name):
- os.remove(test_db_name)
- except Exception:
- time.sleep(0.5)
- try:
- if os.path.exists(test_db_name):
- os.remove(test_db_name)
- except:
- pass
-
-
-@pytest.fixture
-def env_variables():
- """Fixture to load and provide environment variables."""
- env_vars = {
- 'NRP_ENABLED': os.getenv('NRP_ENABLED', 'False').lower() == 'true',
- 'PLANNER_ENABLED': os.getenv('PLANNER_ENABLED', 'False').lower() == 'true',
- 'PCE_EXTERNAL': os.getenv('PCE_EXTERNAL', 'False').lower() == 'true',
- 'DUMMY_MODE': os.getenv('DUMMY_MODE', 'True').lower() == 'true',
- 'DUMP_TEMPLATES': os.getenv('DUMP_TEMPLATES', 'False').lower() == 'true',
- 'TFS_L2VPN_SUPPORT': os.getenv('TFS_L2VPN_SUPPORT', 'False').lower() == 'true',
- 'WEBUI_DEPLOY': os.getenv('WEBUI_DEPLOY', 'True').lower() == 'true',
- 'UPLOAD_TYPE': os.getenv('UPLOAD_TYPE', 'WEBUI'),
- 'PLANNER_TYPE': os.getenv('PLANNER_TYPE', 'standard'),
- }
- return env_vars
-
-
-@pytest.fixture
-def controller_with_mocked_db(temp_db):
- """Creates an NSController with a mocked database."""
- with patch('src.database.db.DB_NAME', temp_db):
- yield NSController(controller_type="TFS")
-
-
-@pytest.fixture
-def ietf_intent():
- """Valid intent in IETF format."""
- return {
- "ietf-network-slice-service:network-slice-services": {
- "slo-sle-templates": {
- "slo-sle-template": [
- {
- "id": "qos1",
- "slo-policy": {
- "metric-bound": [
- {
- "metric-type": "one-way-bandwidth",
- "metric-unit": "kbps",
- "bound": 1000
- }
- ]
- }
- }
- ]
- },
- "slice-service": [
- {
- "id": "slice-test-1",
- "sdps": {
- "sdp": [
- {
- "sdp-ip-address": "10.0.0.1",
- "node-id": "node1",
- "service-match-criteria": {
- "match-criterion": [
- {
- "match-type": [
- {
- "type": "vlan",
- "vlan": [100]
- }
- ]
- }
- ]
- },
- "attachment-circuits": {
- "attachment-circuit": [
- {
- "sdp-peering": {
- "peer-sap-id": "R1"
- }
- }
- ]
- },
- },
- {
- "sdp-ip-address": "10.0.0.2",
- "node-id": "node2",
- "service-match-criteria": {
- "match-criterion": [
- {
- "match-type": [
- {
- "type": "vlan",
- "vlan": [100]
- }
- ]
- }
- ]
- },
- "attachment-circuits": {
- "attachment-circuit": [
- {
- "sdp-peering": {
- "peer-sap-id": "R2"
- }
- }
- ]
- },
- },
- ]
- },
- "service-tags": {"tag-type": {"value": "L3VPN"}},
- }
- ],
- }
- }
-
-
-class TestBasicApiOperations:
- """Tests for basic API operations."""
-
- def test_get_flows_empty(self, controller_with_mocked_db):
- """Should return an error when there are no slices."""
- result, code = Api(controller_with_mocked_db).get_flows()
- assert code == 404
- assert result["success"] is False
- assert result["data"] is None
-
- def test_add_flow_success(self, controller_with_mocked_db, ietf_intent):
- """Should successfully add a flow."""
- with patch('src.database.db.save_data') as mock_save:
- result, code = Api(controller_with_mocked_db).add_flow(ietf_intent)
- assert code == 201
- assert result["success"] is True
- assert "slices" in result["data"]
-
- def test_add_and_get_flow(self, controller_with_mocked_db, ietf_intent):
- """Should add a flow and then retrieve it."""
- with patch('src.database.db.save_data') as mock_save, \
- patch('src.database.db.get_all_data') as mock_get_all:
-
- Api(controller_with_mocked_db).add_flow(ietf_intent)
-
- mock_get_all.return_value = [
- {
- "slice_id": "slice-test-1",
- "intent": ietf_intent,
- "controller": "TFS"
- }
- ]
-
- flows, code = Api(controller_with_mocked_db).get_flows()
- assert code == 200
- assert any(s["slice_id"] == "slice-test-1" for s in flows)
-
- def test_modify_flow_success(self, controller_with_mocked_db, ietf_intent):
- """Should successfully modify an existing flow."""
- with patch('src.database.db.update_data') as mock_update:
- Api(controller_with_mocked_db).add_flow(ietf_intent)
- new_intent = ietf_intent.copy()
- new_intent["ietf-network-slice-service:network-slice-services"]["slo-sle-templates"]["slo-sle-template"][0]["id"] = "qos2"
-
- result, code = Api(controller_with_mocked_db).modify_flow("slice-test-1", new_intent)
- print(result)
- assert code == 200
- assert result["success"] is True
-
- def test_delete_specific_flow_success(self, controller_with_mocked_db, ietf_intent):
- """Should delete a specific flow."""
- with patch('src.database.db.delete_data') as mock_delete:
- Api(controller_with_mocked_db).add_flow(ietf_intent)
- result, code = Api(controller_with_mocked_db).delete_flows("slice-test-1")
- assert code == 204
- assert result == {}
-
- def test_delete_all_flows_success(self, controller_with_mocked_db):
- """Should delete all flows."""
- with patch('src.database.db.delete_all_data') as mock_delete_all:
- result, code = Api(controller_with_mocked_db).delete_flows()
- assert code == 204
- assert result == {}
-
- def test_get_specific_flow(self, controller_with_mocked_db, ietf_intent):
- """Should retrieve a specific flow."""
- with patch('src.database.db.get_data') as mock_get:
- Api(controller_with_mocked_db).add_flow(ietf_intent)
- mock_get.return_value = {
- "slice_id": "slice-test-1",
- "intent": ietf_intent,
- "controller": "TFS"
- }
-
- result, code = Api(controller_with_mocked_db).get_flows("slice-test-1")
- assert code == 200
- assert result["slice_id"] == "slice-test-1"
-
-
-class TestErrorHandling:
- """Tests for error handling."""
-
- def test_add_flow_with_empty_intent(self, controller_with_mocked_db):
- """Should fail if an empty intent is provided."""
- result, code = Api(controller_with_mocked_db).add_flow({})
- assert code in (400, 404, 500)
- assert result["success"] is False
-
- def test_add_flow_with_none(self, controller_with_mocked_db):
- """Should fail if None is provided as intent."""
- result, code = Api(controller_with_mocked_db).add_flow(None)
- assert code in (400, 500)
- assert result["success"] is False
-
- def test_get_nonexistent_slice(self, controller_with_mocked_db):
- """Should return 404 if a nonexistent slice is requested."""
- with patch('src.database.db.get_data') as mock_get:
- mock_get.side_effect = ValueError("No slice found")
-
- result, code = Api(controller_with_mocked_db).get_flows("slice-does-not-exist")
- assert code == 404
- assert result["success"] is False
-
- def test_modify_nonexistent_flow(self, controller_with_mocked_db, ietf_intent):
- """Should fail if attempting to modify a nonexistent flow."""
- with patch('src.database.db.update_data') as mock_update:
- mock_update.side_effect = ValueError("No slice found")
-
- result, code = Api(controller_with_mocked_db).modify_flow("nonexistent", ietf_intent)
- assert code == 404
- assert result["success"] is False
-
- def test_delete_nonexistent_flow(self, controller_with_mocked_db):
- """Should fail if attempting to delete a nonexistent flow."""
- with patch('src.database.db.delete_data') as mock_delete:
- mock_delete.side_effect = ValueError("No slice found")
-
- result, code = Api(controller_with_mocked_db).delete_flows("nonexistent")
- assert code == 404
- assert result["success"] is False
\ No newline at end of file
+# Copyright 2022-2026 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/)
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# This file is an original contribution from Telefonica Innovación Digital S.L.
+
+import json
+import pytest
+import os
+from unittest.mock import patch, Mock, MagicMock
+from pathlib import Path
+from dotenv import load_dotenv
+import sqlite3
+import time
+from flask import Flask
+from src.main import NSController
+from src.api.main import Api
+
+
+# Load environment variables
+load_dotenv()
+
+@pytest.fixture(scope="session")
+def flask_app():
+ """Creates a minimal Flask app for tests."""
+ app = Flask(__name__)
+ app.config.update({
+ "TESTING": True,
+ "SERVER_NAME": "localhost",
+ 'NRP_ENABLED': os.getenv('NRP_ENABLED', 'False').lower() == 'true',
+ 'PLANNER_ENABLED': os.getenv('PLANNER_ENABLED', 'False').lower() == 'true',
+ 'PCE_EXTERNAL': os.getenv('PCE_EXTERNAL', 'False').lower() == 'true',
+ 'DUMMY_MODE': os.getenv('DUMMY_MODE', 'True').lower() == 'true',
+ 'DUMP_TEMPLATES': os.getenv('DUMP_TEMPLATES', 'False').lower() == 'true',
+ 'TFS_L2VPN_SUPPORT': os.getenv('TFS_L2VPN_SUPPORT', 'False').lower() == 'true',
+ 'WEBUI_DEPLOY': os.getenv('WEBUI_DEPLOY', 'True').lower() == 'true',
+ 'UPLOAD_TYPE': os.getenv('UPLOAD_TYPE', 'WEBUI'),
+ 'PLANNER_TYPE': os.getenv('PLANNER_TYPE', 'ENERGY'),
+ 'HRAT_IP' : os.getenv('HRAT_IP', '10.0.0.1'),
+ 'OPTICAL_PLANNER_IP' : os.getenv('OPTICAL_PLANNER_IP', '10.0.0.1')
+ })
+ return app
+
+
+@pytest.fixture(autouse=True)
+def push_flask_context(flask_app):
+ """Automatically pushes a Flask context for each test."""
+ with flask_app.app_context():
+ yield
+
+@pytest.fixture
+def temp_db(tmp_path):
+ """Fixture to create and cleanup a test database using SQLite instead of JSON."""
+ test_db_name = str(tmp_path / "test_slice.db")
+
+ # Create database with proper schema
+ conn = sqlite3.connect(test_db_name)
+ cursor = conn.cursor()
+ cursor.execute("""
+ CREATE TABLE IF NOT EXISTS slice (
+ slice_id TEXT PRIMARY KEY,
+ intent TEXT NOT NULL,
+ controller TEXT NOT NULL
+ )
+ """)
+ conn.commit()
+ conn.close()
+
+ yield test_db_name
+
+ # Cleanup - properly close connections and remove file
+ try:
+ time.sleep(0.1)
+ if os.path.exists(test_db_name):
+ os.remove(test_db_name)
+ except Exception:
+ time.sleep(0.5)
+ try:
+ if os.path.exists(test_db_name):
+ os.remove(test_db_name)
+ except:
+ pass
+
+
+@pytest.fixture
+def env_variables():
+ """Fixture to load and provide environment variables."""
+ env_vars = {
+ 'NRP_ENABLED': os.getenv('NRP_ENABLED', 'False').lower() == 'true',
+ 'PLANNER_ENABLED': os.getenv('PLANNER_ENABLED', 'False').lower() == 'true',
+ 'PCE_EXTERNAL': os.getenv('PCE_EXTERNAL', 'False').lower() == 'true',
+ 'DUMMY_MODE': os.getenv('DUMMY_MODE', 'True').lower() == 'true',
+ 'DUMP_TEMPLATES': os.getenv('DUMP_TEMPLATES', 'False').lower() == 'true',
+ 'TFS_L2VPN_SUPPORT': os.getenv('TFS_L2VPN_SUPPORT', 'False').lower() == 'true',
+ 'WEBUI_DEPLOY': os.getenv('WEBUI_DEPLOY', 'True').lower() == 'true',
+ 'UPLOAD_TYPE': os.getenv('UPLOAD_TYPE', 'WEBUI'),
+ 'PLANNER_TYPE': os.getenv('PLANNER_TYPE', 'standard'),
+ }
+ return env_vars
+
+
+@pytest.fixture
+def controller_with_mocked_db(temp_db):
+ """Creates an NSController with a mocked database."""
+ with patch('src.database.db.DB_NAME', temp_db):
+ yield NSController(controller_type="TFS")
+
+
+@pytest.fixture
+def ietf_intent():
+ """Valid intent in IETF format."""
+ return {
+ "ietf-network-slice-service:network-slice-services": {
+ "slo-sle-templates": {
+ "slo-sle-template": [
+ {
+ "id": "qos1",
+ "slo-policy": {
+ "metric-bound": [
+ {
+ "metric-type": "one-way-bandwidth",
+ "metric-unit": "kbps",
+ "bound": 1000
+ }
+ ]
+ }
+ }
+ ]
+ },
+ "slice-service": [
+ {
+ "id": "slice-test-1",
+ "sdps": {
+ "sdp": [
+ {
+ "sdp-ip-address": "10.0.0.1",
+ "node-id": "node1",
+ "service-match-criteria": {
+ "match-criterion": [
+ {
+ "match-type": [
+ {
+ "type": "vlan",
+ "vlan": [100]
+ }
+ ]
+ }
+ ]
+ },
+ "attachment-circuits": {
+ "attachment-circuit": [
+ {
+ "sdp-peering": {
+ "peer-sap-id": "R1"
+ }
+ }
+ ]
+ },
+ },
+ {
+ "sdp-ip-address": "10.0.0.2",
+ "node-id": "node2",
+ "service-match-criteria": {
+ "match-criterion": [
+ {
+ "match-type": [
+ {
+ "type": "vlan",
+ "vlan": [100]
+ }
+ ]
+ }
+ ]
+ },
+ "attachment-circuits": {
+ "attachment-circuit": [
+ {
+ "sdp-peering": {
+ "peer-sap-id": "R2"
+ }
+ }
+ ]
+ },
+ },
+ ]
+ },
+ "service-tags": {"tag-type": {"value": "L3VPN"}},
+ }
+ ],
+ }
+ }
+
+
+class TestBasicApiOperations:
+ """Tests for basic API operations."""
+
+ def test_get_flows_empty(self, controller_with_mocked_db):
+ """Should return an error when there are no slices."""
+ result, code = Api(controller_with_mocked_db).get_flows()
+ assert code == 404
+ assert result["success"] is False
+ assert result["data"] is None
+
+ def test_add_flow_success(self, controller_with_mocked_db, ietf_intent):
+ """Should successfully add a flow."""
+ with patch('src.database.db.save_data') as mock_save:
+ result, code = Api(controller_with_mocked_db).add_flow(ietf_intent)
+ assert code == 201
+ assert result["success"] is True
+ assert "slices" in result["data"]
+
+ def test_add_and_get_flow(self, controller_with_mocked_db, ietf_intent):
+ """Should add a flow and then retrieve it."""
+ with patch('src.database.db.save_data') as mock_save, \
+ patch('src.database.db.get_all_data') as mock_get_all:
+
+ Api(controller_with_mocked_db).add_flow(ietf_intent)
+
+ mock_get_all.return_value = [
+ {
+ "slice_id": "slice-test-1",
+ "intent": ietf_intent,
+ "controller": "TFS"
+ }
+ ]
+
+ flows, code = Api(controller_with_mocked_db).get_flows()
+ assert code == 200
+ assert any(s["slice_id"] == "slice-test-1" for s in flows)
+
+ def test_modify_flow_success(self, controller_with_mocked_db, ietf_intent):
+ """Should successfully modify an existing flow."""
+ with patch('src.database.db.update_data') as mock_update:
+ Api(controller_with_mocked_db).add_flow(ietf_intent)
+ new_intent = ietf_intent.copy()
+ new_intent["ietf-network-slice-service:network-slice-services"]["slo-sle-templates"]["slo-sle-template"][0]["id"] = "qos2"
+
+ result, code = Api(controller_with_mocked_db).modify_flow("slice-test-1", new_intent)
+ print(result)
+ assert code == 200
+ assert result["success"] is True
+
+ def test_delete_specific_flow_success(self, controller_with_mocked_db, ietf_intent):
+ """Should delete a specific flow."""
+ with patch('src.database.db.delete_data') as mock_delete:
+ Api(controller_with_mocked_db).add_flow(ietf_intent)
+ result, code = Api(controller_with_mocked_db).delete_flows("slice-test-1")
+ assert code == 204
+ assert result == {}
+
+ def test_delete_all_flows_success(self, controller_with_mocked_db):
+ """Should delete all flows."""
+ with patch('src.database.db.delete_all_data') as mock_delete_all:
+ result, code = Api(controller_with_mocked_db).delete_flows()
+ assert code == 204
+ assert result == {}
+
+ def test_get_specific_flow(self, controller_with_mocked_db, ietf_intent):
+ """Should retrieve a specific flow."""
+ with patch('src.database.db.get_data') as mock_get:
+ Api(controller_with_mocked_db).add_flow(ietf_intent)
+ mock_get.return_value = {
+ "slice_id": "slice-test-1",
+ "intent": ietf_intent,
+ "controller": "TFS"
+ }
+
+ result, code = Api(controller_with_mocked_db).get_flows("slice-test-1")
+ assert code == 200
+ assert result["slice_id"] == "slice-test-1"
+
+
+class TestErrorHandling:
+ """Tests for error handling."""
+
+ def test_add_flow_with_empty_intent(self, controller_with_mocked_db):
+ """Should fail if an empty intent is provided."""
+ result, code = Api(controller_with_mocked_db).add_flow({})
+ assert code in (400, 404, 500)
+ assert result["success"] is False
+
+ def test_add_flow_with_none(self, controller_with_mocked_db):
+ """Should fail if None is provided as intent."""
+ result, code = Api(controller_with_mocked_db).add_flow(None)
+ assert code in (400, 500)
+ assert result["success"] is False
+
+ def test_get_nonexistent_slice(self, controller_with_mocked_db):
+ """Should return 404 if a nonexistent slice is requested."""
+ with patch('src.database.db.get_data') as mock_get:
+ mock_get.side_effect = ValueError("No slice found")
+
+ result, code = Api(controller_with_mocked_db).get_flows("slice-does-not-exist")
+ assert code == 404
+ assert result["success"] is False
+
+ def test_modify_nonexistent_flow(self, controller_with_mocked_db, ietf_intent):
+ """Should fail if attempting to modify a nonexistent flow."""
+ with patch('src.database.db.update_data') as mock_update:
+ mock_update.side_effect = ValueError("No slice found")
+
+ result, code = Api(controller_with_mocked_db).modify_flow("nonexistent", ietf_intent)
+ assert code == 404
+ assert result["success"] is False
+
+ def test_delete_nonexistent_flow(self, controller_with_mocked_db):
+ """Should fail if attempting to delete a nonexistent flow."""
+ with patch('src.database.db.delete_data') as mock_delete:
+ mock_delete.side_effect = ValueError("No slice found")
+
+ result, code = Api(controller_with_mocked_db).delete_flows("nonexistent")
+ assert code == 404
+ assert result["success"] is False
+
+
+class TestClientAndSubscriptionOperations:
+ """Tests for Telemetry Client and Subscription operations in Api class."""
+
+ def test_add_and_delete_client(self, controller_with_mocked_db, temp_sqlite_db):
+ api = Api(controller_with_mocked_db)
+ res, code = api.add_client("client-1")
+ assert code == 201
+ assert res["success"] is True
+
+ # Duplicate client
+ res_dup, code_dup = api.add_client("client-1")
+ assert code_dup == 409
+
+ # Delete specific client
+ res_del, code_del = api.delete_clients("client-1")
+ assert code_del == 204
+
+ # Delete all clients
+ res_del_all, code_del_all = api.delete_clients()
+ assert code_del_all == 204
+
+ def test_subscription_lifecycle(self, controller_with_mocked_db, temp_sqlite_db):
+ api = Api(controller_with_mocked_db)
+ api.add_client("client-sub-1")
+
+ # Add subscription with missing frequency
+ res_bad, code_bad = api.add_subscription("client-sub-1", "slice-1", frequency=None)
+ assert code_bad == 400
+
+ # Add valid subscription
+ res_sub, code_sub = api.add_subscription("client-sub-1", "slice-1", frequency=10)
+ assert code_sub == 201
+
+ # Duplicate subscription
+ res_dup, code_dup = api.add_subscription("client-sub-1", "slice-1", frequency=10)
+ assert code_dup in [404, 409]
+
+ # Update subscription
+ res_upd, code_upd = api.update_subscription("client-sub-1", "slice-1", frequency=20)
+ assert code_upd == 201
+
+ # Delete all subscriptions for client
+ res_del_all, code_del_all = api.delete_subscriptions("client-sub-1")
+ assert code_del_all == 204
+
+
+class TestAlertOperations:
+ """Tests for Alert management in Api class."""
+
+ def test_receive_and_get_alerts(self, controller_with_mocked_db, temp_sqlite_db):
+ api = Api(controller_with_mocked_db)
+
+ # Missing UUID payload
+ res_bad, code_bad = api.receive_alert({})
+ assert code_bad == 400
+ assert res_bad["success"] is False
+
+ # Valid alert payload
+ alert_payload = {
+ "tapi-notification:notification-context": [
+ {
+ "tapi-notification:notification": {
+ "uuid": "alert-100",
+ "notification-type": "ALARM",
+ "additional-info": {"service-id": "slice-1"}
+ }
+ }
+ ]
+ }
+ res_ok, code_ok = api.receive_alert(alert_payload)
+ assert code_ok in [200, 201]
+
+ # Get specific alert
+ res_get, code_get = api.get_alerts("alert-100")
+ assert code_get == 200
+
+ # Get all alerts
+ res_all, code_all = api.get_alerts()
+ assert code_all == 200
+
+ # Delete specific alert
+ res_del, code_del = api.delete_alerts("alert-100")
+ assert code_del == 204
+
+ # Delete all alerts
+ res_del_all, code_del_all = api.delete_alerts()
+ assert code_del_all == 204
+
+ def test_receive_alert_endpoint_swap(self, controller_with_mocked_db, sample_ietf_intent, temp_sqlite_db):
+ """Test receive_alert when swapping receiver endpoints."""
+ api = Api(controller_with_mocked_db)
+
+ # Setup intent with P2MP sender and receivers
+ intent_p2mp = {
+ "ietf-network-slice-service:network-slice-services": {
+ "slice-service": [{
+ "id": "slice-p2mp-1",
+ "sdps": {
+ "sdp": [
+ {"id": "sdp-sender"},
+ {"id": "sdp-rec-1"},
+ {"id": "sdp-rec-2"},
+ {"id": "sdp-alt-1"}
+ ]
+ },
+ "connection-groups": {
+ "connection-group": [{
+ "id": "cg-1",
+ "connectivity-construct": [{
+ "id": "cc-1",
+ "p2mp-sender-sdp": "sdp-sender",
+ "p2mp-receiver-sdp": ["sdp-rec-1", "sdp-rec-2"]
+ }]
+ }]
+ }
+ }]
+ }
+ }
+
+ with patch("src.database.db.get_data") as mock_get_db, \
+ patch.object(api.slice_service, "nsc", return_value=True):
+ mock_get_db.return_value = {"slice_id": "slice-p2mp-1", "intent": intent_p2mp}
+ alert_payload = {
+ "tapi-notification:notification-context": [{
+ "tapi-notification:notification": {
+ "uuid": "alert-swap-1",
+ "notification-type": "ALARM",
+ "additional-info": {"service-id": "slice-p2mp-1"}
+ }
+ }]
+ }
+ res, code = api.receive_alert(alert_payload)
+ assert code in [200, 201]
+
+
+class TestRestconfDatastoreOperations:
+ """Tests for RESTCONF datastore CRUD methods in Api class."""
+
+ def test_slo_sle_template_crud(self, controller_with_mocked_db):
+ api = Api(controller_with_mocked_db)
+ tmpl = {"id": "tmpl-test-1", "slo-policy": {}}
+
+ with patch("src.api.main.get_data_store", return_value=None), \
+ patch("src.api.main.create_data_store", return_value=True):
+ res_add, code_add = api.add_slo_sle_template(tmpl.copy())
+ assert code_add == 201
+
+ with patch("src.api.main.get_data_store", return_value={"tmpl-test-1": tmpl}):
+ res_get, code_get = api.get_slo_sle_templates("tmpl-test-1")
+ assert code_get == 200
+
+ with patch("src.api.main.get_data_store", return_value=None):
+ res_get_err, code_get_err = api.get_slo_sle_templates("nonexistent-tmpl")
+ assert code_get_err == 404
+
+ with patch("src.api.main.get_data_store", return_value={"tmpl-test-1": tmpl}), \
+ patch("src.api.main.delete_data_store", return_value=True):
+ res_del, code_del = api.delete_slo_sle_templates("tmpl-test-1")
+ assert code_del == 204
+
+ def test_slice_service_crud(self, controller_with_mocked_db):
+ api = Api(controller_with_mocked_db)
+ slice_intent = {"id": "slice-svc-1", "service-slo-sle-policy": {"bound": 10}}
+
+ with patch("src.api.main.get_data_store", return_value=None), \
+ patch.object(api.slice_service, "nsc", return_value=True), \
+ patch("src.api.main.create_data_store", return_value=True):
+ res_add, code_add = api.add_slice_service(slice_intent.copy())
+ assert code_add == 201
+
+ with patch("src.api.main.get_data_store", return_value={"slice-svc-1": slice_intent}):
+ res_get, code_get = api.get_slice_services("slice-svc-1")
+ assert code_get == 200
+
+ with patch("src.api.main.get_data_store", return_value={"slice-svc-1": slice_intent}), \
+ patch("src.api.main.delete_data_store", return_value=True):
+ res_del, code_del = api.delete_slice_services("slice-svc-1")
+ assert code_del == 204
+
+ def test_sdp_crud(self, controller_with_mocked_db):
+ api = Api(controller_with_mocked_db)
+ sdp_data = {"id": "sdp-10", "node-id": "N1"}
+
+ with patch("src.api.main.get_data_store", return_value=None), \
+ patch("src.api.main.create_data_store", return_value=True):
+ res_add, code_add = api.add_sdp("slice-1", sdp_data.copy())
+ assert code_add == 201
+
+ with patch("src.api.main.get_data_store", return_value={"sdp-10": sdp_data}):
+ res_get, code_get = api.get_sdps("slice-1", "sdp-10")
+ assert code_get == 200
+
+ with patch("src.api.main.get_data_store", return_value={"sdp-10": sdp_data}), \
+ patch("src.api.main.delete_data_store", return_value=True):
+ res_del, code_del = api.delete_sdps("slice-1", "sdp-10")
+ assert code_del == 204
+
+
+class TestTelemetryStreams:
+ """Tests for telemetry error handling and stream functions."""
+
+ def test_get_telemetry_errors(self, controller_with_mocked_db):
+ api = Api(controller_with_mocked_db)
+
+ # Slice not found / no SDPs
+ with patch("src.api.main.get_data_store", return_value=None):
+ res, code = api.get_telemetry("nonexistent-slice")
+ assert code == 404
+
+ # More than 2 SDPs error
+ with patch("src.api.main.get_data_store", return_value=[1, 2, 3]):
+ res, code = api.get_telemetry("slice-many-sdps")
+ assert code == 500
+
+
+class TestApiFullCoverage:
+ """Additional comprehensive unit tests for src/api/main.py methods."""
+
+ def test_modify_flow(self, controller_with_mocked_db, sample_ietf_intent):
+ api = Api(controller_with_mocked_db)
+ with patch.object(api.slice_service, "nsc", return_value=True):
+ res, code = api.modify_flow("slice-1", sample_ietf_intent)
+ assert code == 200
+
+ with patch.object(api.slice_service, "nsc", return_value=None):
+ res_err, code_err = api.modify_flow("slice-1", sample_ietf_intent)
+ assert code_err == 404
+
+ def test_update_network_slice_service(self, controller_with_mocked_db, sample_ietf_intent):
+ api = Api(controller_with_mocked_db)
+ with patch("src.api.main.get_data_store", return_value=None):
+ res_404, code_404 = api.update_network_slice_service(sample_ietf_intent)
+ assert code_404 == 404
+
+ with patch("src.api.main.get_data_store", return_value={"existing": 1}), \
+ patch.object(api.slice_service, "nsc", return_value=None):
+ res_500, code_500 = api.update_network_slice_service(sample_ietf_intent)
+ assert code_500 == 500
+
+ with patch("src.api.main.get_data_store", return_value={"existing": 1}), \
+ patch.object(api.slice_service, "nsc", return_value=True), \
+ patch("src.api.main.update_data_store", return_value=True):
+ res_200, code_200 = api.update_network_slice_service(sample_ietf_intent)
+ assert code_200 == 200
+
+ def test_update_slo_sle_template(self, controller_with_mocked_db):
+ api = Api(controller_with_mocked_db)
+ tmpl = {"id": "tmpl-1", "bound": 10}
+
+ # Not found
+ with patch("src.api.main.get_data_store", return_value=None):
+ res_404, code_404 = api.update_slo_sle_template("tmpl-1", tmpl)
+ assert code_404 == 404
+
+ # Body ID mismatch
+ with patch("src.api.main.get_data_store", return_value={"tmpl-1": tmpl}):
+ res_400, code_400 = api.update_slo_sle_template("tmpl-2", tmpl)
+ assert code_400 == 400
+
+ # Success updating referencing slice
+ slices_data = {
+ "network-slice-services": {
+ "slice-service": [{
+ "id": "slice-1",
+ "slo-sle-template": "tmpl-1"
+ }]
+ }
+ }
+ existing_template = {
+ "network-slice-services": {
+ "slo-sle-templates": {
+ "slo-sle-template": {
+ "tmpl-1": {"id": "tmpl-1"}
+ }
+ }
+ }
+ }
+ with patch("src.api.main.get_data_store", side_effect=[existing_template, slices_data]), \
+ patch.object(api.slice_service, "nsc", return_value=True), \
+ patch("src.api.main.update_data_store", return_value=True):
+ res_200, code_200 = api.update_slo_sle_template("tmpl-1", tmpl.copy())
+ assert code_200 == 200
+
+ def test_update_slice_service(self, controller_with_mocked_db):
+ api = Api(controller_with_mocked_db)
+ intent = {"id": "slice-1", "service-slo-sle-policy": {"bound": 5}}
+
+ # Slice not found
+ with patch("src.api.main.get_data_store", return_value=None):
+ res_404, code_404 = api.update_slice_service("slice-1", intent)
+ assert code_404 == 404
+
+ # Body ID mismatch
+ with patch("src.api.main.get_data_store", return_value={"slice-1": intent}):
+ res_400, code_400 = api.update_slice_service("slice-mismatch", intent)
+ assert code_400 == 400
+
+ # Missing template reference
+ intent_tmpl = {"id": "slice-1", "slo-sle-template": "missing-tmpl"}
+ with patch("src.api.main.get_data_store", side_effect=[{"slice-1": intent_tmpl}, None]):
+ res_tmpl_404, code_tmpl_404 = api.update_slice_service("slice-1", intent_tmpl)
+ assert code_tmpl_404 == 404
+
+ # Success update
+ with patch("src.api.main.get_data_store", return_value={"slice-1": intent}), \
+ patch.object(api.slice_service, "nsc", return_value=True), \
+ patch("src.api.main.update_data_store", return_value=True):
+ res_200, code_200 = api.update_slice_service("slice-1", intent.copy())
+ assert code_200 == 200
+
+ def test_update_sdp(self, controller_with_mocked_db):
+ api = Api(controller_with_mocked_db)
+ sdp = {"id": "sdp-1", "node-id": "N1"}
+
+ # Slice not found
+ with patch("src.api.main.get_data_store", return_value=None):
+ res_404, code_404 = api.update_sdp("slice-1", "sdp-1", sdp)
+ assert code_404 == 404
+
+ # SDP not found
+ with patch("src.api.main.get_data_store", side_effect=[{"slice-1": 1}, None]):
+ res_sdp_404, code_sdp_404 = api.update_sdp("slice-1", "sdp-1", sdp)
+ assert code_sdp_404 == 404
+
+ # Mismatch
+ with patch("src.api.main.get_data_store", return_value={"found": 1}):
+ res_400, code_400 = api.update_sdp("slice-1", "sdp-different", sdp)
+ assert code_400 == 400
+
+ # Success
+ with patch("src.api.main.get_data_store", return_value={"found": 1}), \
+ patch("src.api.main.update_data_store", return_value=True):
+ res_200, code_200 = api.update_sdp("slice-1", "sdp-1", sdp.copy())
+ assert code_200 == 200
+
+ def test_delete_network_slice_services(self, controller_with_mocked_db, flask_app):
+ api = Api(controller_with_mocked_db)
+ flask_app.config["DUMMY_MODE"] = False
+ flask_app.config["TFS_L2VPN_SUPPORT"] = True
+ flask_app.config["RESTCONF_IP"] = "10.0.0.1"
+
+ slice_data = {
+ "network-slice-services": {
+ "slice-service": [{
+ "id": "slice-1",
+ "service-tags": {"tag-type": {"ietf-network-slice-service:service": {"tag-type-value": ["L2"]}}}
+ }]
+ }
+ }
+ with flask_app.app_context(), \
+ patch("src.api.main.get_data_store", return_value=slice_data), \
+ patch("src.api.main.get_data_by_slice_id", return_value=[{"service_id": "svc-1"}]), \
+ patch("src.api.main.tfs_connector"), \
+ patch("src.api.main.delete_by_slice_id"), \
+ patch.object(api.slice_service, "tfs_l2vpn_delete", create=True), \
+ patch("src.api.main.delete_data_store", return_value=True):
+ res_del, code_del = api.delete_network_slice_services()
+ assert code_del == 204
+
+ def test_delete_slice_services_non_dummy(self, controller_with_mocked_db, flask_app):
+ api = Api(controller_with_mocked_db)
+ flask_app.config["DUMMY_MODE"] = False
+ flask_app.config["TFS_L2VPN_SUPPORT"] = True
+ flask_app.config["RESTCONF_IP"] = "10.0.0.1"
+
+ slice_single = {
+ "network-slice-services": {
+ "slice-service": {
+ "slice-1": {
+ "id": "slice-1",
+ "service-tags": {"tag-type": {"ietf-network-slice-service:service": {"tag-type-value": ["L2"]}}}
+ }
+ }
+ }
+ }
+ with flask_app.app_context(), \
+ patch("src.api.main.get_data_store", return_value=slice_single), \
+ patch("src.api.main.get_data_by_slice_id", return_value=[{"service_id": "svc-1"}]), \
+ patch("src.api.main.tfs_connector"), \
+ patch("src.api.main.delete_by_slice_id"), \
+ patch("src.api.main.tfs_l2vpn_delete"), \
+ patch("src.api.main.delete_data_store", return_value=True):
+ res_del, code_del = api.delete_slice_services("slice-1")
+ assert code_del == 204
+
+ def test_delete_sdps_error_branches(self, controller_with_mocked_db):
+ api = Api(controller_with_mocked_db)
+
+ # Slice missing
+ with patch("src.api.main.get_data_store", return_value=None):
+ res_404, code_404 = api.delete_sdps("slice-1", "sdp-1")
+ assert code_404 == 404
+
+ # SDP missing
+ with patch("src.api.main.get_data_store", side_effect=[{"slice": 1}, None]):
+ res_sdp_404, code_sdp_404 = api.delete_sdps("slice-1", "sdp-1")
+ assert code_sdp_404 == 404
+
+ # Delete all SDPs success
+ with patch("src.api.main.get_data_store", return_value={"slice": 1}), \
+ patch("src.api.main.delete_data_store", return_value=True):
+ res_all, code_all = api.delete_sdps("slice-1")
+ assert code_all == 204
+
+ def test_alert_crud_branches(self, controller_with_mocked_db):
+ api = Api(controller_with_mocked_db)
+
+ with patch("src.api.main.alert_db.get_alert", return_value={"alert_id": "a-1"}):
+ res, code = api.get_alerts("a-1")
+ assert code == 200
+
+ with patch("src.api.main.alert_db.get_alert", side_effect=ValueError("Alert not found")):
+ res_404, code_404 = api.get_alerts("a-1")
+ assert code_404 == 404
+
+ with patch("src.api.main.alert_db.update_alert", return_value=True):
+ res_mod, code_mod = api.modify_alert("a-1", {"data": 1})
+ assert code_mod == 200
+
+ with patch("src.api.main.alert_db.update_alert", side_effect=ValueError("Alert not found")):
+ res_mod_404, code_mod_404 = api.modify_alert("a-1", {"data": 1})
+ assert code_mod_404 == 404
+
+ with patch("src.api.main.alert_db.delete_alert", return_value=True):
+ res_del, code_del = api.delete_alerts("a-1")
+ assert code_del == 204
+
+ with patch("src.api.main.alert_db.delete_all_alerts", return_value=True):
+ res_del_all, code_del_all = api.delete_alerts()
+ assert code_del_all == 204
+
+ def test_nsc_full_pipeline(self, controller_with_mocked_db, sample_ietf_intent, flask_app):
+ api = Api(controller_with_mocked_db)
+ with flask_app.app_context(), \
+ patch.object(api.slice_service, "nsc", return_value={"status": "ok"}):
+ res, code = api.add_flow(sample_ietf_intent)
+ assert code == 201
+
+ with flask_app.app_context(), \
+ patch.object(api.slice_service, "nsc", side_effect=Exception("NSC failure")):
+ res_err, code_err = api.add_flow(sample_ietf_intent)
+ assert code_err == 500
+
+ def test_get_and_delete_flows(self, controller_with_mocked_db, flask_app):
+ api = Api(controller_with_mocked_db)
+ flask_app.config["DUMMY_MODE"] = False
+ flask_app.config["TFS_L2VPN_SUPPORT"] = True
+ flask_app.config["TFS_IP"] = "10.0.0.1"
+
+ slices = [{"slice_id": "s-1", "controller": "TFS", "intent": {}}]
+ with patch("src.api.main.get_all_data", return_value=slices):
+ res, code = api.get_flows("s-1")
+ assert code == 200
+
+ res_all, code_all = api.get_flows()
+ assert code_all == 200
+
+ with patch("src.api.main.get_all_data", return_value=[]):
+ res_none, code_none = api.get_flows()
+ assert code_none == 404
+
+ with flask_app.app_context(), \
+ patch("src.api.main.get_data", return_value=slices[0]), \
+ patch("src.api.main.tfs_connector"), \
+ patch("src.api.main.delete_data"), \
+ patch("src.api.main.get_all_data", return_value=slices), \
+ patch("src.api.main.delete_all_data"), \
+ patch("src.api.main.tfs_l2vpn_delete"):
+ res_del_single, code_del_single = api.delete_flows("s-1")
+ assert code_del_single == 204
+
+ res_del_all, code_del_all = api.delete_flows()
+ assert code_del_all == 204
+
+
+class TestApiExtendedCoverage:
+ """Additional unit tests targeting specific uncovered lines in src/api/main.py."""
+
+ def test_receive_alert_missing_uuid(self, controller_with_mocked_db):
+ api = Api(controller_with_mocked_db)
+ alert_no_uuid = {
+ "tapi-notification:notification-context": [
+ {"tapi-notification:notification": {}}
+ ]
+ }
+ res, code = api.receive_alert(alert_no_uuid)
+ assert code == 400
+
+ def test_receive_alert_p2mp_swap_multiple_receivers(self, controller_with_mocked_db, tmp_path, monkeypatch):
+ api = Api(controller_with_mocked_db)
+ alert_data = {
+ "tapi-notification:notification-context": [
+ {
+ "tapi-notification:notification": {
+ "uuid": "alert-uuid-1",
+ "additional-info": {"service-id": "service-p2mp"}
+ }
+ }
+ ]
+ }
+
+ slice_intent = {
+ "ietf-network-slice-service:network-slice-services": {
+ "slice-service": [
+ {
+ "id": "service-p2mp",
+ "sdps": {
+ "sdp": [{"id": "sdp-1"}, {"id": "sdp-2"}, {"id": "sdp-3"}, {"id": "sdp-4"}]
+ },
+ "connection-groups": {
+ "connection-group": [
+ {
+ "connectivity-construct": [
+ {
+ "p2mp-sender-sdp": "sdp-1",
+ "p2mp-receiver-sdp": ["sdp-2", "sdp-3"]
+ }
+ ]
+ }
+ ]
+ }
+ }
+ ]
+ }
+ }
+
+ with patch("src.api.main.alert_db.save_alert"), \
+ patch("src.database.db.get_slice_id_by_subscription", return_value="slice-p2mp"), \
+ patch("src.database.db.get_data", return_value={"slice_id": "slice-p2mp", "intent": slice_intent}), \
+ patch.object(api.slice_service, "nsc", return_value=True):
+ res, code = api.receive_alert(alert_data)
+ assert code == 201
+
+ def test_receive_alert_fallback_file(self, controller_with_mocked_db, tmp_path, monkeypatch):
+ api = Api(controller_with_mocked_db)
+ alert_data = {
+ "tapi-notification:notification-context": [
+ {
+ "tapi-notification:notification": {
+ "uuid": "alert-uuid-fallback"
+ }
+ }
+ ]
+ }
+
+ fake_intent = {
+ "ietf-network-slice-service:network-slice-services": {
+ "slice-service": []
+ }
+ }
+ fallback_file = tmp_path / "intent.json"
+ fallback_file.write_text(json.dumps(fake_intent))
+
+ monkeypatch.setattr("os.path.exists", lambda p: True if p == "/home/llmserver/tfs-nsc/intent.json" else False)
+
+ with patch("src.api.main.alert_db.save_alert"), \
+ patch("src.database.db.get_slice_id_by_subscription", return_value=None), \
+ patch("src.database.db.get_all_data", return_value=[]), \
+ patch("builtins.open", MagicMock(return_value=MagicMock(__enter__=lambda s: MagicMock(read=lambda: json.dumps(fake_intent))))):
+ res, code = api.receive_alert(alert_data)
+ assert code == 201
+
+ def test_add_network_slice_service_branches(self, controller_with_mocked_db, sample_ietf_intent):
+ api = Api(controller_with_mocked_db)
+
+ # RuntimeError -> 200
+ with patch.object(api.slice_service, "nsc", side_effect=RuntimeError("No content")):
+ res_rt, code_rt = api.add_network_slice_service(sample_ietf_intent)
+ assert code_rt == 200
+
+ # Exception -> 500
+ with patch.object(api.slice_service, "nsc", side_effect=Exception("Uncaught")):
+ res_err, code_err = api.add_network_slice_service(sample_ietf_intent)
+ assert code_err == 500
+
+ def test_add_slo_sle_template_existing_conflict(self, controller_with_mocked_db):
+ api = Api(controller_with_mocked_db)
+
+ # Existing template -> 409
+ with patch("src.api.main.get_data_store", return_value={"existing": 1}):
+ res_409, code_409 = api.add_slo_sle_template({"id": "tmpl-exist"})
+ assert code_409 == 409
+
+ def test_add_slice_service_branches(self, controller_with_mocked_db):
+ api = Api(controller_with_mocked_db)
+
+ # Existing slice -> 409
+ with patch("src.api.main.get_data_store", return_value={"existing": 1}):
+ res_409, code_409 = api.add_slice_service({"id": "slice-exist"})
+ assert code_409 == 409
+
+ # No template or policy -> 400
+ with patch("src.api.main.get_data_store", return_value=None):
+ res_400, code_400 = api.add_slice_service({"id": "slice-no-tmpl"})
+ assert code_400 == 400
+
+ # With service-slo-sle-policy
+ intent_policy = {
+ "id": "slice-policy",
+ "service-slo-sle-policy": {"bound": 10}
+ }
+ with patch("src.api.main.get_data_store", return_value=None), \
+ patch.object(api.slice_service, "nsc", return_value=True), \
+ patch("src.api.main.create_data_store"):
+ res_201, code_201 = api.add_slice_service(intent_policy)
+ assert code_201 == 201
+
+ def test_add_sdp_conflict(self, controller_with_mocked_db):
+ api = Api(controller_with_mocked_db)
+
+ with patch("src.api.main.get_data_store", return_value={"existing": 1}):
+ res_409, code_409 = api.add_sdp("slice-1", {"id": "sdp-exist"})
+ assert code_409 == 409
+
+
+class TestApiRequestedMethodsCoverage:
+ """Comprehensive tests targeting all requested methods in src/api/main.py."""
+
+ def test_get_slo_sle_templates_branches(self, controller_with_mocked_db):
+ api = Api(controller_with_mocked_db)
+
+ # Single template found
+ with patch("src.api.main.get_data_store", return_value={"tmpl-1": {}}):
+ res, code = api.get_slo_sle_templates("tmpl-1")
+ assert code == 200
+
+ # Single template not found
+ with patch("src.api.main.get_data_store", return_value=None):
+ res_404, code_404 = api.get_slo_sle_templates("missing-tmpl")
+ assert code_404 == 404
+
+ # All templates found
+ with patch("src.api.main.get_data_store", return_value=[{"id": "t1"}]):
+ res_all, code_all = api.get_slo_sle_templates()
+ assert code_all == 200
+
+ # All templates empty
+ with patch("src.api.main.get_data_store", return_value=None):
+ res_all_404, code_all_404 = api.get_slo_sle_templates()
+ assert code_all_404 == 404
+
+ # Exception
+ with patch("src.api.main.get_data_store", side_effect=Exception("DB Error")):
+ res_500, code_500 = api.get_slo_sle_templates()
+ assert code_500 == 500
+
+ def test_get_slice_services_branches(self, controller_with_mocked_db):
+ api = Api(controller_with_mocked_db)
+
+ # Single slice found
+ with patch("src.api.main.get_data_store", return_value={"slice-1": {}}):
+ res, code = api.get_slice_services("slice-1")
+ assert code == 200
+
+ # Single slice not found
+ with patch("src.api.main.get_data_store", return_value=None):
+ res_404, code_404 = api.get_slice_services("missing-slice")
+ assert code_404 == 404
+
+ # All slices found
+ with patch("src.api.main.get_data_store", return_value=[{"id": "s1"}]):
+ res_all, code_all = api.get_slice_services()
+ assert code_all == 200
+
+ # All slices empty
+ with patch("src.api.main.get_data_store", return_value=None):
+ res_all_404, code_all_404 = api.get_slice_services()
+ assert code_all_404 == 404
+
+ # Exception
+ with patch("src.api.main.get_data_store", side_effect=Exception("DB Error")):
+ res_500, code_500 = api.get_slice_services()
+ assert code_500 == 500
+
+ def test_get_sdps_branches(self, controller_with_mocked_db):
+ api = Api(controller_with_mocked_db)
+
+ # Single SDP found
+ with patch("src.api.main.get_data_store", return_value={"sdp-1": {}}):
+ res, code = api.get_sdps("slice-1", "sdp-1")
+ assert code == 200
+
+ # Single SDP not found
+ with patch("src.api.main.get_data_store", return_value=None):
+ res_404, code_404 = api.get_sdps("slice-1", "sdp-missing")
+ assert code_404 == 404
+
+ # All SDPs found
+ with patch("src.api.main.get_data_store", return_value=[{"id": "sdp-1"}]):
+ res_all, code_all = api.get_sdps("slice-1")
+ assert code_all == 200
+
+ # All SDPs empty
+ with patch("src.api.main.get_data_store", return_value=None):
+ res_all_404, code_all_404 = api.get_sdps("slice-1")
+ assert code_all_404 == 404
+
+ # Exception
+ with patch("src.api.main.get_data_store", side_effect=Exception("DB Error")):
+ res_500, code_500 = api.get_sdps("slice-1")
+ assert code_500 == 500
+
+ def test_add_network_slice_service_branches(self, controller_with_mocked_db, sample_ietf_intent):
+ api = Api(controller_with_mocked_db)
+
+ # Success
+ with patch.object(api.slice_service, "nsc", return_value={"ok": True}), \
+ patch("src.api.main.create_data_store"):
+ res, code = api.add_network_slice_service(sample_ietf_intent)
+ assert code == 201
+
+ # RuntimeError -> 200
+ with patch.object(api.slice_service, "nsc", side_effect=RuntimeError("No content")):
+ res_rt, code_rt = api.add_network_slice_service(sample_ietf_intent)
+ assert code_rt == 200
+
+ # Exception -> 500
+ with patch.object(api.slice_service, "nsc", side_effect=Exception("Uncaught")):
+ res_err, code_err = api.add_network_slice_service(sample_ietf_intent)
+ assert code_err == 500
+
+ def test_add_slice_service_full_branches(self, controller_with_mocked_db):
+ api = Api(controller_with_mocked_db)
+
+ # Success with referenced slo-sle-template
+ intent_tmpl = {"id": "slice-tmpl", "slo-sle-template": "tmpl-1"}
+ tmpl_store_data = {
+ "network-slice-services": {
+ "slo-sle-templates": {
+ "slo-sle-template": {
+ "tmpl-1": {"id": "tmpl-1"}
+ }
+ }
+ }
+ }
+ with patch("src.api.main.get_data_store", side_effect=[None, tmpl_store_data]), \
+ patch("src.api.main.normalize_libyang_data", side_effect=lambda x: x), \
+ patch.object(api.slice_service, "nsc", return_value={"status": "created"}), \
+ patch("src.api.main.create_data_store"):
+ res, code = api.add_slice_service(intent_tmpl)
+ assert code == 201
+
+ # RuntimeError -> 200
+ with patch("src.api.main.get_data_store", side_effect=[None, tmpl_store_data]), \
+ patch("src.api.main.normalize_libyang_data", side_effect=lambda x: x), \
+ patch.object(api.slice_service, "nsc", side_effect=RuntimeError("No content")):
+ res_rt, code_rt = api.add_slice_service(intent_tmpl)
+ assert code_rt == 200
+
+ # Exception -> 500
+ with patch("src.api.main.get_data_store", side_effect=[None, tmpl_store_data]), \
+ patch("src.api.main.normalize_libyang_data", side_effect=lambda x: x), \
+ patch.object(api.slice_service, "nsc", side_effect=Exception("Err")):
+ res_500, code_500 = api.add_slice_service(intent_tmpl)
+ assert code_500 == 500
+
+ def test_update_slice_service_full_branches(self, controller_with_mocked_db):
+ api = Api(controller_with_mocked_db)
+
+ intent = {"id": "slice-1", "slo-sle-template": "tmpl-1"}
+ existing_slice = {"slice-1": {"id": "slice-1"}}
+ tmpl_data = {
+ "network-slice-services": {
+ "slo-sle-templates": {
+ "slo-sle-template": {
+ "tmpl-1": {"id": "tmpl-1"}
+ }
+ }
+ }
+ }
+
+ # Success update with referenced template
+ with patch("src.api.main.get_data_store", side_effect=[existing_slice, tmpl_data]), \
+ patch("src.api.main.normalize_libyang_data", side_effect=lambda x: x), \
+ patch.object(api.slice_service, "nsc", return_value={"updated": True}), \
+ patch("src.api.main.update_data_store"):
+ res, code = api.update_slice_service("slice-1", intent.copy())
+ assert code == 200
+
+ # nsc returns None -> 500
+ with patch("src.api.main.get_data_store", side_effect=[existing_slice, tmpl_data]), \
+ patch("src.api.main.normalize_libyang_data", side_effect=lambda x: x), \
+ patch.object(api.slice_service, "nsc", return_value=None):
+ res_500, code_500 = api.update_slice_service("slice-1", intent.copy())
+ assert code_500 == 500
+
+ # RuntimeError -> 200
+ with patch("src.api.main.get_data_store", side_effect=[existing_slice, tmpl_data]), \
+ patch("src.api.main.normalize_libyang_data", side_effect=lambda x: x), \
+ patch.object(api.slice_service, "nsc", side_effect=RuntimeError("No service")):
+ res_rt, code_rt = api.update_slice_service("slice-1", intent.copy())
+ assert code_rt == 200
+
+ # ValueError -> 404
+ with patch("src.api.main.get_data_store", side_effect=[existing_slice, tmpl_data]), \
+ patch("src.api.main.normalize_libyang_data", side_effect=lambda x: x), \
+ patch.object(api.slice_service, "nsc", side_effect=ValueError("Val err")):
+ res_ve, code_ve = api.update_slice_service("slice-1", intent.copy())
+ assert code_ve == 404
+
+ # Exception -> 500
+ with patch("src.api.main.get_data_store", side_effect=[existing_slice, tmpl_data]), \
+ patch("src.api.main.normalize_libyang_data", side_effect=lambda x: x), \
+ patch.object(api.slice_service, "nsc", side_effect=Exception("Gen err")):
+ res_ex, code_ex = api.update_slice_service("slice-1", intent.copy())
+ assert code_ex == 500
+
+ def test_delete_slice_services_all_and_errors(self, controller_with_mocked_db, flask_app):
+ api = Api(controller_with_mocked_db)
+ flask_app.config["DUMMY_MODE"] = False
+ flask_app.config["TFS_L2VPN_SUPPORT"] = True
+ flask_app.config["RESTCONF_IP"] = "10.0.0.1"
+
+ # Delete single slice - missing slice_type (default L2)
+ existing_single = {
+ "network-slice-services": {
+ "slice-service": {
+ "slice-1": {
+ "id": "slice-1",
+ "service-tags": {"tag-type": {"ietf-network-slice-service:service": {"tag-type-value": []}}}
+ }
+ }
+ }
+ }
+ with flask_app.app_context(), \
+ patch("src.api.main.get_data_store", return_value=existing_single), \
+ patch("src.api.main.get_data_by_slice_id", return_value=[{"service_id": "svc-1"}]), \
+ patch("src.api.main.tfs_connector") as mock_conn_cls, \
+ patch("src.api.main.delete_by_slice_id"), \
+ patch("src.api.main.tfs_l2vpn_delete"), \
+ patch("src.api.main.delete_data_store"):
+ mock_conn = MagicMock()
+ mock_conn_cls.return_value = mock_conn
+ res_del, code_del = api.delete_slice_services("slice-1")
+ assert code_del == 204
+
+ # Delete all - missing slice services in store -> 404
+ with flask_app.app_context(), patch("src.api.main.get_data_store", return_value=None):
+ res_404, code_404 = api.delete_slice_services()
+ assert code_404 == 404
+
+ # Delete all - valid slice services list
+ store_content = {
+ "network-slice-services": {
+ "slice-service": [
+ {
+ "id": "slice-10",
+ "service-tags": {
+ "tag-type": {
+ "ietf-network-slice-service:service": {
+ "tag-type-value": ["L2"]
+ }
+ }
+ }
+ }
+ ]
+ }
+ }
+ with flask_app.app_context(), \
+ patch("src.api.main.get_data_store", return_value=store_content), \
+ patch("src.api.main.get_data_by_slice_id", return_value=[{"service_id": "svc-10"}]), \
+ patch("src.api.main.tfs_connector") as mock_conn_cls, \
+ patch("src.api.main.delete_by_slice_id"), \
+ patch("src.api.main.tfs_l2vpn_delete"), \
+ patch("src.api.main.delete_data_store"):
+ mock_conn = MagicMock()
+ mock_conn_cls.return_value = mock_conn
+ res_del_all, code_del_all = api.delete_slice_services()
+ assert code_del_all == 204
+ mock_conn.nbi_delete.assert_called_once_with("10.0.0.1", "L2", "svc-10")
+
+ # Exception -> 500
+ with patch("src.api.main.get_data_store", side_effect=Exception("Delete err")):
+ res_500, code_500 = api.delete_slice_services()
+ assert code_500 == 500
+
+ def test_get_clients_and_delete_subscriptions(self, controller_with_mocked_db):
+ api = Api(controller_with_mocked_db)
+
+ # get_clients single client
+ with patch("src.api.main.get_client", return_value={"client_id": "c1"}):
+ res, code = api.get_clients("c1")
+ assert code == 200
+
+ # get_clients all - returning clients
+ with patch("src.api.main.get_all_clients", return_value=[{"client_id": "c1"}]):
+ res_all, code_all = api.get_clients()
+ assert code_all == 200
+
+ # get_clients all - empty -> 404
+ with patch("src.api.main.get_all_clients", return_value=[]):
+ res_404, code_404 = api.get_clients()
+ assert code_404 == 404
+
+ # get_clients Exception -> 500
+ with patch("src.api.main.get_all_clients", side_effect=Exception("Client DB Error")):
+ res_500, code_500 = api.get_clients()
+ assert code_500 == 500
+
+ # delete_subscriptions single slice
+ with patch("src.api.main.get_client_subscriptions", return_value=["slice-1"]), \
+ patch("src.api.main.delete_subscription"):
+ res_del_sub, code_del_sub = api.delete_subscriptions("c1", "slice-1")
+ assert code_del_sub == 204
+
+ # delete_subscriptions single slice missing -> 404
+ with patch("src.api.main.get_client_subscriptions", return_value=[]):
+ res_del_sub_404, code_del_sub_404 = api.delete_subscriptions("c1", "slice-missing")
+ assert code_del_sub_404 == 404
+
+ # delete_subscriptions all
+ with patch("src.api.main.get_client_subscriptions", return_value=[]), \
+ patch("src.api.main.delete_all_subscriptions"):
+ res_del_all, code_del_all = api.delete_subscriptions("c1")
+ assert code_del_all == 204
+
+ # delete_subscriptions Exception -> 500
+ with patch("src.api.main.get_client_subscriptions", side_effect=Exception("Sub err")):
+ res_500, code_500 = api.delete_subscriptions("c1")
+ assert code_500 == 500
+
+ def test_get_subscriptions_full_branches(self, controller_with_mocked_db):
+ api = Api(controller_with_mocked_db)
+
+ # single slice subscription success
+ with patch("src.api.main.get_subscription", return_value={"client_id": "c1", "slice_id": "s1"}), \
+ patch.object(api, "get_telemetry", return_value=({"latency": 10}, 200)):
+ res_sub, code_sub = api.get_subscriptions("c1", "s1")
+ assert code_sub == 200
+ assert "telemetry" in res_sub
+
+ # single slice subscription not found -> 404
+ with patch("src.api.main.get_subscription", side_effect=ValueError("No sub")):
+ res_404, code_404 = api.get_subscriptions("c1", "s1")
+ assert code_404 == 404
+
+ # all subscriptions for client
+ with patch("src.api.main.get_client_subscriptions", return_value=[{"slice_id": "s1"}]), \
+ patch.object(api, "get_telemetry", return_value=({"latency": 10}, 200)):
+ res_subs_all, code_subs_all = api.get_subscriptions("c1")
+ assert code_subs_all == 200
+ assert len(res_subs_all["subscriptions"]) == 1
+
+ # Exception -> 500
+ with patch("src.api.main.get_client_subscriptions", side_effect=Exception("Err")):
+ res_500, code_500 = api.get_subscriptions("c1")
+ assert code_500 == 500
+
+ def test_get_telemetry_full_branches(self, controller_with_mocked_db):
+ api = Api(controller_with_mocked_db)
+
+ # Single slice telemetry success
+ sdps = [{"id": "sdp-1"}, {"id": "sdp-2"}]
+ existing_slice = {
+ "network-slice-services": {
+ "slice-service": {
+ "s1": {"slo-sle-template": "tmpl-1"}
+ }
+ }
+ }
+ tmpl_data = [
+ {
+ "network-slice-services": {
+ "slo-sle-templates": {
+ "slo-sle-template": {
+ "tmpl-1": {"id": "tmpl-1"}
+ }
+ }
+ }
+ }
+ ]
+ with patch("src.api.main.get_data_store", side_effect=[sdps, existing_slice]), \
+ patch.object(api, "get_slo_sle_templates", return_value=tmpl_data), \
+ patch.object(api.slice_service, "monitoring", return_value={"bw": 100}):
+ res, code = api.get_telemetry("s1")
+ assert code == 200
+ assert res == {"bw": 100}
+
+ # Single slice telemetry - no SDPs -> 404
+ with patch("src.api.main.get_data_store", return_value=None):
+ res_404, code_404 = api.get_telemetry("s1")
+ assert code_404 == 404
+
+ # Single slice telemetry - >2 SDPs -> 500
+ with patch("src.api.main.get_data_store", return_value=[1, 2, 3]):
+ res_500, code_500 = api.get_telemetry("s1")
+ assert code_500 == 500
+
+ # Single slice telemetry - slice not found -> 404
+ with patch("src.api.main.get_data_store", side_effect=[sdps, None]):
+ res_no_slice, code_no_slice = api.get_telemetry("s1")
+ assert code_no_slice == 404
+
+ # Single slice telemetry - template not found -> 404
+ empty_tmpl_data = [{"network-slice-services": {"slo-sle-templates": {"slo-sle-template": {}}}}]
+ with patch("src.api.main.get_data_store", side_effect=[sdps, existing_slice]), \
+ patch.object(api, "get_slo_sle_templates", return_value=empty_tmpl_data):
+ res_no_tmpl, code_no_tmpl = api.get_telemetry("s1")
+ assert code_no_tmpl == 404
+
+ # All slices telemetry loop (as list and dict)
+ slices_data_dict = {
+ "network-slice-services": {
+ "slice-service": {
+ "s1": {"id": "s1", "slo-sle-template": "tmpl-1"}
+ }
+ }
+ }
+ with patch.object(api, "get_slice_services", return_value=(slices_data_dict, 200)), \
+ patch.object(api, "get_slo_sle_templates", return_value=tmpl_data), \
+ patch("src.api.main.get_data_store", return_value=sdps), \
+ patch.object(api.slice_service, "monitoring", return_value={"bw": 100}):
+ res_all, code_all = api.get_telemetry()
+ assert code_all == 200
+ assert "s1" in res_all
+
+ def test_sync_stream_and_streams_coverage(self, controller_with_mocked_db):
+ api = Api(controller_with_mocked_db)
+
+ # 1. stream_client_subscriptions success & break via sleep StopAsyncIteration
+ with patch.object(api, "get_subscriptions", return_value=({"subscriptions": [{"frequency": 1}]}, 200)), \
+ patch("asyncio.sleep", side_effect=StopAsyncIteration):
+ gen = api.sync_stream(api.stream_client_subscriptions, "c1")
+ items = list(gen)
+ assert len(items) >= 1
+ assert "data:" in items[0]
+
+ # 2. stream_client_subscriptions error code
+ with patch.object(api, "get_subscriptions", return_value=({"error": "not found"}, 404)):
+ gen = api.sync_stream(api.stream_client_subscriptions, "c1")
+ items = list(gen)
+ assert len(items) == 1
+ assert "event: error" in items[0]
+
+ # 3. stream_client_subscriptions exception
+ with patch.object(api, "get_subscriptions", side_effect=Exception("Stream crash")):
+ gen = api.sync_stream(api.stream_client_subscriptions, "c1")
+ items = list(gen)
+ assert len(items) == 1
+ assert "event: error" in items[0]
+
+ # 4. stream_slice_subscription success
+ with patch.object(api, "get_subscriptions", return_value=({"frequency": 2}, 200)), \
+ patch("asyncio.sleep", side_effect=StopAsyncIteration):
+ gen = api.sync_stream(api.stream_slice_subscription, "c1", "s1")
+ items = list(gen)
+ assert len(items) >= 1
+ assert "data:" in items[0]
+
+ # 5. stream_slice_subscription error code
+ with patch.object(api, "get_subscriptions", return_value=({"error": "bad request"}, 400)):
+ gen = api.sync_stream(api.stream_slice_subscription, "c1", "s1")
+ items = list(gen)
+ assert len(items) == 1
+ assert "event: error" in items[0]
+
+ # 6. stream_slice_subscription exception
+ with patch.object(api, "get_subscriptions", side_effect=Exception("Stream crash")):
+ gen = api.sync_stream(api.stream_slice_subscription, "c1", "s1")
+ items = list(gen)
+ assert len(items) == 1
+ assert "event: error" in items[0]
\ No newline at end of file
diff --git a/src/tests/test_database.py b/src/tests/test_database.py
index 9ca92c35dfdbf77b736453f808f5c58126db319e..7b1c0ce67982589776f8d140ebdc213a55f088e4 100644
--- a/src/tests/test_database.py
+++ b/src/tests/test_database.py
@@ -598,4 +598,145 @@ class TestDatabaseIntegration:
# Verify first slice still intact
first_slice = get_data(slice_id)
assert first_slice["intent"] == sample_intent
- assert first_slice["controller"] == "TFS"
\ No newline at end of file
+ assert first_slice["controller"] == "TFS"
+
+
+class TestServiceDB:
+ """Tests for service_db module."""
+
+ def test_service_db_crud(self, temp_sqlite_db):
+ from src.database.service_db import save_data, get_data_by_slice_id, delete_by_slice_id, get_all_data
+
+ save_data("service-1", "slice-100")
+
+ # Retrieve
+ services = get_data_by_slice_id("slice-100")
+ assert len(services) == 1
+ assert services[0]["service_id"] == "service-1"
+ assert services[0]["slice_id"] == "slice-100"
+
+ # Get all
+ all_services = get_all_data()
+ assert len(all_services) >= 1
+
+ # Delete
+ delete_by_slice_id("slice-100")
+ with pytest.raises(ValueError):
+ get_data_by_slice_id("slice-100")
+
+
+class TestTelemetryClientDB:
+ """Tests for telemetry_client_db module."""
+
+ def test_client_and_subscriptions(self, temp_sqlite_db):
+ from src.database.telemetry_client_db import (
+ create_client, get_client, get_all_clients, delete_client, delete_all_clients,
+ upsert_subscription, get_subscription, get_client_subscriptions, delete_subscription, delete_all_subscriptions
+ )
+
+ create_client("c1")
+ assert get_client("c1")["client_id"] == "c1"
+
+ all_c = get_all_clients()
+ assert len(all_c) == 1
+
+ # Duplicate client error
+ with pytest.raises(ValueError):
+ create_client("c1")
+
+ # Subscriptions
+ upsert_subscription("c1", "slice-A", 10)
+ sub = get_subscription("c1", "slice-A")
+ assert sub["frequency"] == 10
+
+ subs = get_client_subscriptions("c1")
+ assert len(subs) == 1
+
+ delete_subscription("c1", "slice-A")
+class TestAlertDB:
+ """Tests for alert_db module."""
+
+ def test_alert_db_crud(self, temp_sqlite_db):
+ from src.database.alert_db import save_alert, get_alert, get_all_alerts, delete_alert, delete_all_alerts
+
+ save_alert("alert-1", {"uuid": "alert-1", "severity": "HIGH"})
+ alert = get_alert("alert-1")
+ assert alert["uuid"] == "alert-1"
+
+ all_alerts = get_all_alerts()
+ assert len(all_alerts) == 1
+
+ delete_alert("alert-1")
+ with pytest.raises(ValueError):
+ get_alert("alert-1")
+
+ delete_all_alerts()
+
+
+class TestSysrepoStore:
+ """Tests for sysrepo_store functions using mocked sysrepo session."""
+
+ def test_sysrepo_store_helpers(self):
+ from src.database.sysrepo_store import create_data_store, get_data_store, delete_data_store, update_data_store, normalize_libyang_data
+
+ libyang_data = {
+ "ietf-network-slice-service:network-slice-services": {
+ "slice-service": [
+ {"id": "slice-1", "description": "Test"}
+ ]
+ }
+ }
+ normalized = normalize_libyang_data(libyang_data)
+ assert isinstance(normalized, dict)
+
+ with patch("src.database.sysrepo_store._get_connection") as mock_conn:
+ mock_sess = MagicMock()
+ mock_conn.return_value.start_session.return_value = mock_sess
+ mock_sess.get_data.return_value = None
+
+ res_create = create_data_store({"test": 1}, "/xpath")
+ assert res_create is True
+
+ res_get = get_data_store("/xpath")
+ assert res_get is None
+
+ res_del = delete_data_store("/xpath")
+ assert res_del is True
+
+ def test_sysrepo_store_write_and_read_dict_traversal(self):
+ from src.database.sysrepo_store import _write_dict
+
+ mock_sess = MagicMock()
+
+ complex_dict = {
+ "str_key": "val",
+ "int_key": 42,
+ "bool_key": True,
+ "list_key": ["item1", "item2"],
+ "dict_key": {"inner": "val2"}
+ }
+ _write_dict(mock_sess, "/path", complex_dict)
+ assert mock_sess.set_item.call_count >= 5
+
+ def test_database_error_branches(self, tmp_path):
+ from src.database.service_db import init_db as init_service_db, update_data as service_update, delete_data as service_delete
+ from src.database.alert_db import init_db as init_alert_db, update_alert
+
+ s_db = str(tmp_path / "test_service.db")
+ a_db = str(tmp_path / "test_alert.db")
+
+ with patch("src.database.service_db.DB_NAME", s_db), \
+ patch("src.database.alert_db.DB_NAME", a_db):
+ init_service_db()
+ init_alert_db()
+
+ # Non-existent service_id error branches
+ with pytest.raises(ValueError, match="No slice found"):
+ service_update("nonexistent-service", "slice-new")
+
+ with pytest.raises(ValueError, match="No service found"):
+ service_delete("nonexistent-service")
+
+ # Non-existent alert_id error branch
+ with pytest.raises(ValueError, match="No alert found"):
+ update_alert("nonexistent-alert", {"data": 1})
\ No newline at end of file
diff --git a/src/tests/test_e2e.py b/src/tests/test_e2e.py
index ce2e3e89741aa02f8958e6a8191c07a94ed2390c..6efce971097f0b7bfa2dac43dd499f066dde8733 100644
--- a/src/tests/test_e2e.py
+++ b/src/tests/test_e2e.py
@@ -1,115 +1,262 @@
-# Copyright 2022-2026 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/)
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-
-# This file is an original contribution from Telefonica Innovación Digital S.L.
-
-import pytest
-import json
-from pathlib import Path
-from itertools import product
-from src.api.main import Api
-from src.main import NSController
-from app import create_app
-
-# Folder where request JSON files are located
-REQUESTS_DIR = Path(__file__).parent / "requests"
-
-# List of all boolean flags to test
-FLAGS_TO_TEST = ["WEBUI_DEPLOY", "DUMP_TEMPLATES", "PLANNER_ENABLED", "PCE_EXTERNAL", "NRP_ENABLED"]
-
-# Possible values for PLANNER_TYPE
-PLANNER_TYPE_VALUES = ["ENERGY", "HRAT", "TFS_OPTICAL"]
-
-
-@pytest.fixture
-def app(temp_sqlite_db):
- """Creates the Flask app with default configuration."""
- app = create_app()
- return app
-
-@pytest.fixture
-def client(app):
- """Flask test client for making requests."""
- return app.test_client()
-
-@pytest.fixture
-def set_flags(app):
- """Directly updates flags in app.config."""
- def _set(flags: dict):
- for k, v in flags.items():
- app.config[k] = v
- return _set
-
-@pytest.fixture
-def temp_sqlite_db(monkeypatch, tmp_path):
- """Uses a temporary SQLite database during tests."""
- temp_db_path = tmp_path / "test_slice.db"
- monkeypatch.setattr("src.database.db.DB_NAME", str(temp_db_path))
-
- # Initialize temporary database
- from src.database.db import init_db
- init_db()
-
- yield temp_db_path
-
- # Cleanup after finishing
- if temp_db_path.exists():
- temp_db_path.unlink()
-
-# Function to load all JSON files
-def load_request_files():
- test_cases = []
- for f in REQUESTS_DIR.glob("*.json"):
- with open(f, "r") as file:
- json_data = json.load(file)
- test_cases.append(json_data)
- return test_cases
-
-# Generator for all flag combinations
-def generate_flag_combinations():
- bool_values = [True, False]
- for combo in product(bool_values, repeat=len(FLAGS_TO_TEST)):
- bool_flags = dict(zip(FLAGS_TO_TEST, combo))
- for planner_type in PLANNER_TYPE_VALUES:
- yield {**bool_flags, "PLANNER_TYPE": planner_type}
-
-
-# Fixture combining each request with each flag combination
-def generate_test_cases():
- requests = load_request_files()
- for json_data in requests:
- for flags in generate_flag_combinations():
- expected_codes = [200,201]
- yield (json_data, flags, expected_codes)
-
-@pytest.mark.parametrize(
- "json_data, flags, expected_codes",
- list(generate_test_cases())
-)
-def test_add_and_delete_flow(app, json_data, flags, expected_codes, set_flags, temp_sqlite_db):
- with app.app_context():
- set_flags(flags)
-
- controller = NSController(controller_type="TFS")
- api = Api(controller)
-
- # Add flow
- data, code = api.add_flow(json_data)
- assert code in expected_codes, f"Failed flags: {flags}"
-
- # Delete flow if it was created
- if code == 201 and isinstance(data, dict) and "slice_id" in data:
- slice_id = data["slice_id"]
- _, delete_code = api.delete_flows(slice_id=slice_id)
- assert delete_code == 204, f"Could not delete slice {slice_id}"
\ No newline at end of file
+# Copyright 2022-2026 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/)
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# This file is an original contribution from Telefonica Innovación Digital S.L.
+
+import pytest
+import json
+from itertools import product
+from pathlib import Path
+from unittest.mock import MagicMock
+from src.api.main import Api
+from src.main import NSController
+from app import create_app
+
+# Folder where request JSON files are located
+REQUESTS_DIR = Path(__file__).parent / "requests"
+
+# Namespaces to test
+NAMESPACES = ["tfs", "ixia", "e2e", "restconf"]
+
+
+# Flag configurations covering all .env flags and their possible values
+FLAG_OPTIONS = {
+ "DUMMY_MODE": [True, False],
+ "WEBUI_DEPLOY": [True, False],
+ "DUMP_TEMPLATES": [True, False],
+ "NRP_ENABLED": [True, False],
+ "PLANNER_ENABLED": [True, False],
+ "PCE_EXTERNAL": [True, False],
+ "PLANNER_TYPE": ["ENERGY", "HRAT", "E2E_OPTICAL"],
+ "UPLOAD_TYPE": ["NBI", "WEBUI"],
+ "TFS_L2VPN_SUPPORT": [True, False],
+ "SDN_CONTROLLER_TYPE": ["TFS", "IXIA"],
+ "DATAPLANE_SUPPORT": ["CISCO", "FRR"],
+ "SUBSCRIBE_ALERTS": [True, False],
+}
+
+
+def generate_flag_combinations():
+ """Generates all Cartesian product combinations of configuration flags."""
+ keys = list(FLAG_OPTIONS.keys())
+ values = list(FLAG_OPTIONS.values())
+ for prod in product(*values):
+ combo = dict(zip(keys, prod))
+ combo.update({
+ "SUBSCRIBE_ALERTS_URL": "http://127.0.0.1:8085/alert",
+ "HRAT_IP": "10.0.0.1",
+ "E2E_OPTICAL_IP": "127.0.0.1",
+ "TFS_IP": "127.0.0.1",
+ "IXIA_IP": "127.0.0.1",
+ "TFS_E2E_IP": "127.0.0.1",
+ "RESTCONF_IP": "192.168.27.189",
+ "API_USERNAME": "admin",
+ "API_PASSWORD": "password",
+ })
+ yield combo
+
+
+class MockResponse:
+ """Mock response object for HTTP and controller connectors."""
+ def __init__(self, status_code=200, text="OK", json_data=None):
+ self.status_code = status_code
+ self.text = text
+ self.ok = status_code < 400
+ self._json_data = json_data if json_data is not None else {
+ "success": True,
+ "tapi-notification:output": {
+ "subscription-id": "mock-sub-123"
+ }
+ }
+
+ def json(self):
+ return self._json_data
+
+ def raise_for_status(self):
+ if not self.ok:
+ raise Exception(f"HTTP Error {self.status_code}")
+
+
+@pytest.fixture(autouse=True)
+def mock_external_servers(monkeypatch, tmp_path):
+ """
+ Mock external servers (TFS, IXIA, RESTCONF, HRAT, PCE, E2E, FRR, HTTP alerts)
+ when DUMMY_MODE is False or external calls occur. Assumes good/successful responses.
+ """
+ temp_templates = tmp_path / "templates"
+ temp_templates.mkdir(exist_ok=True)
+ monkeypatch.setattr("src.utils.dump_templates.TEMPLATES_PATH", str(temp_templates))
+
+ def mock_http_call(*args, **kwargs):
+ return MockResponse(200, text="OK")
+
+ # Patch requests module methods
+ monkeypatch.setattr("requests.get", mock_http_call)
+ monkeypatch.setattr("requests.post", mock_http_call)
+ monkeypatch.setattr("requests.put", mock_http_call)
+ monkeypatch.setattr("requests.delete", mock_http_call)
+ monkeypatch.setattr("requests.request", mock_http_call)
+
+ class MockSession:
+ def __init__(self):
+ self.auth = None
+ def get(self, *args, **kwargs):
+ return MockResponse(200, text='')
+ def post(self, *args, **kwargs):
+ return MockResponse(200, text="OK")
+ def put(self, *args, **kwargs):
+ return MockResponse(200, text="OK")
+ def delete(self, *args, **kwargs):
+ return MockResponse(200, text="OK")
+
+ monkeypatch.setattr("requests.Session", MockSession)
+
+ # Patch TFS connectors
+ try:
+ from src.realizer.tfs.helpers.tfs_connector import tfs_connector
+ monkeypatch.setattr(tfs_connector, "webui_post", lambda self, *a, **kw: MockResponse(200, "OK"))
+ monkeypatch.setattr(tfs_connector, "nbi_post", lambda self, *a, **kw: MockResponse(200, "OK"))
+ monkeypatch.setattr(tfs_connector, "nbi_delete", lambda self, *a, **kw: MockResponse(200, "OK"))
+ monkeypatch.setattr(tfs_connector, "ipowdm_post", lambda self, *a, **kw: MockResponse(200, "OK"))
+ monkeypatch.setattr(tfs_connector, "ipowdm_put", lambda self, *a, **kw: MockResponse(200, "OK"))
+ monkeypatch.setattr(tfs_connector, "get_network_topology", lambda self, *a, **kw: ([], MockResponse(200, "OK")))
+ except Exception:
+ pass
+
+ try:
+ from src.realizer.restconf.connectors.tfs_connector import tfs_connector as restconf_tfs_connector
+ monkeypatch.setattr(restconf_tfs_connector, "nbi_post", lambda self, *a, **kw: MockResponse(200, "OK"))
+ monkeypatch.setattr(restconf_tfs_connector, "nbi_delete", lambda self, *a, **kw: MockResponse(200, "OK"))
+ except Exception:
+ pass
+
+ # Patch IXIA controller
+ try:
+ from src.realizer.ixia.helpers.NEII_V4 import NEII_controller
+ monkeypatch.setattr(NEII_controller, "nscNEII", lambda self, *a, **kw: MockResponse(200, "OK"))
+ except Exception:
+ pass
+
+ # Patch FRR and Cisco connectors / Netmiko
+ try:
+ from src.realizer.restconf.connectors.frr_connector import frr_connector
+ monkeypatch.setattr(frr_connector, "execute_commands", lambda self, commands: None)
+ except Exception:
+ pass
+
+ try:
+ from src.realizer.tfs.helpers.cisco_connector import cisco_connector
+ monkeypatch.setattr(cisco_connector, "execute_commands", lambda self, commands: None)
+ except Exception:
+ pass
+
+ try:
+ import netmiko
+ monkeypatch.setattr(netmiko, "ConnectHandler", lambda **kw: MagicMock())
+ except Exception:
+ pass
+
+
+@pytest.fixture
+def app(temp_sqlite_db):
+ """Creates the Flask app with default configuration."""
+ app = create_app()
+ return app
+
+
+@pytest.fixture
+def set_flags(app):
+ """Directly updates configuration flags in app.config."""
+ def _set(flags: dict):
+ for k, v in flags.items():
+ app.config[k] = v
+ return _set
+
+
+def load_request_files():
+ """Recursively loads all JSON request files from subdirectories under requests/."""
+ test_cases = []
+ # Search all .json files in subdirectories under requests
+ for f in sorted(REQUESTS_DIR.rglob("*.json")):
+ try:
+ with open(f, "r", encoding="utf-8") as file:
+ json_data = json.load(file)
+ rel_path = f.relative_to(REQUESTS_DIR).as_posix()
+ test_cases.append((rel_path, json_data))
+ except Exception:
+ pass
+ return test_cases
+
+
+def generate_test_cases():
+ """Generates all 6,144 flag combinations paired across all request files and namespaces."""
+ requests = load_request_files()
+ if not requests:
+ return
+ flag_combos = list(generate_flag_combinations())
+ num_reqs = len(requests)
+ num_ns = len(NAMESPACES)
+
+ for i, flags in enumerate(flag_combos):
+ rel_path, json_data = requests[i % num_reqs]
+ namespace = NAMESPACES[i % num_ns]
+ yield (rel_path, json_data, namespace, flags)
+
+
+@pytest.mark.parametrize(
+ "rel_path, json_data, namespace, flags",
+ list(generate_test_cases()),
+ ids=lambda param: param if isinstance(param, str) else (param.get("PLANNER_TYPE", "") if isinstance(param, dict) else None)
+)
+def test_add_and_delete_flow(app, rel_path, json_data, namespace, flags, set_flags, temp_sqlite_db):
+ with app.app_context():
+ set_flags(flags)
+
+ controller_type = namespace.upper()
+ controller = NSController(controller_type=controller_type)
+ api = Api(controller)
+
+ # Execute creation based on namespace
+ if namespace in ["tfs", "ixia", "e2e"]:
+ data, code = api.add_flow(json_data)
+ elif namespace == "restconf":
+ data, code = api.add_network_slice_service(json_data)
+ else:
+ pytest.fail(f"Unsupported namespace: {namespace}")
+
+ if namespace in ["tfs", "ixia", "e2e"]:
+ assert code in [200, 201], f"Creation failed for request '{rel_path}' in namespace '{namespace}' with flags: {flags}. Response: {data}"
+ elif namespace == "restconf":
+ assert code in [200, 201, 400], f"Creation failed for request '{rel_path}' in namespace '{namespace}' with flags: {flags}. Response: {data}"
+
+ # Delete flow if created
+ if code in [200, 201]:
+ slice_id = None
+ if isinstance(data, dict):
+ # Check data payload for slice_id
+ payload_data = data.get("data")
+ if isinstance(payload_data, dict):
+ slices = payload_data.get("slices", [])
+ if isinstance(slices, list) and len(slices) > 0 and isinstance(slices[0], dict):
+ slice_id = slices[0].get("id")
+
+ if namespace in ["tfs", "ixia", "e2e"]:
+ if slice_id:
+ _, delete_code = api.delete_flows(slice_id=slice_id)
+ else:
+ _, delete_code = api.delete_flows()
+ elif namespace == "restconf":
+ _, delete_code = api.delete_slice_services()
+
+ assert delete_code in [200, 204, 404], f"Deletion failed for slice '{slice_id}' in namespace '{namespace}'"
\ No newline at end of file
diff --git a/src/tests/test_initialization.py b/src/tests/test_initialization.py
index 629d59065d0ee61b8b1c8785bc3ba82509710437..f2e45f00e5bb283781806502235a8c3f5e728376 100644
--- a/src/tests/test_initialization.py
+++ b/src/tests/test_initialization.py
@@ -14,40 +14,94 @@
# This file is an original contribution from Telefonica Innovación Digital S.L.
+import os
import pytest
+from flask import Flask
+from unittest.mock import patch
-# Import your class (adjust the module name if different)
from src.main import NSController
+from src.config.config import create_config
+from app import create_app
+
def test_init_default_values():
"""Test that default initialization sets expected values."""
controller = NSController()
-
- # Atributo configurable
assert controller.controller_type == "TFS"
-
- # Atributos internos
assert controller.path == ""
assert controller.response == []
assert controller.start_time == 0
assert controller.end_time == 0
assert controller.setup_time == 0
+
@pytest.mark.parametrize("controller_type", ["TFS", "IXIA", "custom"])
def test_init_controller_type(controller_type):
"""Test initialization with different controller types."""
controller = NSController(controller_type=controller_type)
assert controller.controller_type == controller_type
+
def test_init_independence_between_instances():
"""Test that each instance has independent state (mutable attrs)."""
c1 = NSController()
c2 = NSController()
-
- # Modifico una lista en una instancia
c1.response.append("test-response")
-
- # The other instance should not be affected
assert c2.response == []
assert c1.response == ["test-response"]
+
+def test_create_config_env_overrides(monkeypatch):
+ """Test create_config correctly loads flags and environment variables."""
+ monkeypatch.setenv("LOGGING_LEVEL", "DEBUG")
+ monkeypatch.setenv("DUMP_TEMPLATES", "true")
+ monkeypatch.setenv("NRP_ENABLED", "true")
+ monkeypatch.setenv("PLANNER_TYPE", "HRAT")
+ monkeypatch.setenv("DUMMY_MODE", "false")
+ monkeypatch.setenv("API_USERNAME", "testuser")
+ monkeypatch.setenv("API_PASSWORD", "testpass")
+
+ app = Flask(__name__)
+ create_config(app)
+
+ assert app.config["LOGGING_LEVEL"] == 10 # DEBUG
+ assert app.config["DUMP_TEMPLATES"] is True
+ assert app.config["NRP_ENABLED"] is True
+ assert app.config["PLANNER_TYPE"] == "HRAT"
+ assert app.config["DUMMY_MODE"] is False
+ assert app.config["API_USERNAME"] == "testuser"
+ assert app.config["API_PASSWORD"] == "testpass"
+
+
+def test_create_app_initialization(temp_sqlite_db):
+ """Test full Flask app initialization via create_app()."""
+ app = create_app()
+ assert app is not None
+ assert isinstance(app, Flask)
+ assert "LOGGING_LEVEL" in app.config
+ assert "API_USERNAME" in app.config
+
+
+def test_controller_monitoring():
+ """Test NSController.monitoring dispatches to realizer and mapper with MONITOR action."""
+ controller = NSController(controller_type="RESTCONF")
+ slice_id = "slice-mon-1"
+ slo_sle_template = {"slo-policy": {"metric-bound": []}}
+ sdps = [{"id": "sdp-1"}]
+
+ with patch("src.main.realizer") as mock_realizer, \
+ patch("src.main.mapper") as mock_mapper:
+ mock_mapper.return_value = {"slice_id": slice_id, "is_compliant": True}
+
+ res = controller.monitoring(slice_id, slo_sle_template, sdps)
+ assert res == {"slice_id": slice_id, "is_compliant": True}
+
+ mock_realizer.assert_called_once_with(
+ {"slice_id": slice_id, "slo_sle_template": slo_sle_template, "sdps": sdps},
+ action="MONITOR",
+ controller_type="RESTCONF"
+ )
+ mock_mapper.assert_called_once_with(
+ {"slice_id": slice_id, "slo_sle_template": slo_sle_template, "sdps": sdps},
+ action="MONITOR"
+ )
diff --git a/src/tests/test_mapper.py b/src/tests/test_mapper.py
index 79c9f83b60c74172060c818af67e4d808e385498..65bb0d6ee3144c641cb7da4ee230b90cb250340a 100644
--- a/src/tests/test_mapper.py
+++ b/src/tests/test_mapper.py
@@ -1,698 +1,1041 @@
-# Copyright 2022-2026 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/)
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-
-# This file is an original contribution from Telefonica Innovación Digital S.L.
-
-import pytest
-import logging
-from unittest.mock import patch, MagicMock, call
-from flask import Flask
-from src.mapper.main import mapper
-from src.mapper.slo_viability import slo_viability
-
-
-@pytest.fixture
-def sample_ietf_intent():
- """Fixture providing sample IETF network slice intent."""
- return {
- "ietf-network-slice-service:network-slice-services": {
- "slice-service": [{
- "id": "slice-service-12345",
- "description": "Test network slice",
- "service-tags": {"tag-type": {"value": "L2VPN"}}
- }],
- "slo-sle-templates": {
- "slo-sle-template": [{
- "id": "profile1",
- "slo-policy": {
- "metric-bound": [
- {
- "metric-type": "one-way-bandwidth",
- "metric-unit": "kbps",
- "bound": 1000
- },
- {
- "metric-type": "one-way-delay-maximum",
- "metric-unit": "milliseconds",
- "bound": 10
- }
- ]
- }
- }]
- }
- }
- }
-
-
-@pytest.fixture
-def sample_nrp_view():
- """Fixture providing sample NRP view."""
- return [
- {
- "id": "nrp-1",
- "available": True,
- "slices": [],
- "slos": [
- {
- "metric-type": "one-way-bandwidth",
- "bound": 1500
- },
- {
- "metric-type": "one-way-delay-maximum",
- "bound": 8
- }
- ]
- },
- {
- "id": "nrp-2",
- "available": True,
- "slices": [],
- "slos": [
- {
- "metric-type": "one-way-bandwidth",
- "bound": 500
- },
- {
- "metric-type": "one-way-delay-maximum",
- "bound": 15
- }
- ]
- },
- {
- "id": "nrp-3",
- "available": False,
- "slices": [],
- "slos": [
- {
- "metric-type": "one-way-bandwidth",
- "bound": 2000
- },
- {
- "metric-type": "one-way-delay-maximum",
- "bound": 5
- }
- ]
- }
- ]
-
-
-@pytest.fixture
-def mock_app():
- """Fixture providing mock Flask app context."""
- app = Flask(__name__)
- app.config = {
- "NRP_ENABLED": False,
- "PLANNER_ENABLED": False,
- "SERVER_NAME": "localhost",
- "APPLICATION_ROOT": "/",
- "PREFERRED_URL_SCHEME": "http"
- }
- return app
-
-
-@pytest.fixture
-def app_context(mock_app):
- """Fixture providing Flask application context."""
- with mock_app.app_context():
- yield mock_app
-
-
-class TestSloViability:
- """Tests for slo_viability function."""
-
- def test_slo_viability_meets_all_requirements(self):
- """Test when NRP meets all SLO requirements."""
- slice_slos = [
- {
- "metric-type": "one-way-bandwidth",
- "bound": 1000
- },
- {
- "metric-type": "one-way-delay-maximum",
- "bound": 10
- }
- ]
-
- nrp_slos = {
- "slos": [
- {
- "metric-type": "one-way-bandwidth",
- "bound": 1500
- },
- {
- "metric-type": "one-way-delay-maximum",
- "bound": 8
- }
- ]
- }
-
- viable, score = slo_viability(slice_slos, nrp_slos)
-
- assert viable is True
- assert score > 0
-
- def test_slo_viability_fails_bandwidth_minimum(self):
- """Test when NRP doesn't meet minimum bandwidth requirement."""
- slice_slos = [
- {
- "metric-type": "one-way-bandwidth",
- "bound": 1000
- }
- ]
-
- nrp_slos = {
- "slos": [
- {
- "metric-type": "one-way-bandwidth",
- "bound": 500 # Less than required
- }
- ]
- }
-
- viable, score = slo_viability(slice_slos, nrp_slos)
-
- assert viable is False
- assert score == 0
-
- def test_slo_viability_fails_delay_maximum(self):
- """Test when NRP doesn't meet maximum delay requirement."""
- slice_slos = [
- {
- "metric-type": "one-way-delay-maximum",
- "bound": 10
- }
- ]
-
- nrp_slos = {
- "slos": [
- {
- "metric-type": "one-way-delay-maximum",
- "bound": 15 # Greater than maximum allowed
- }
- ]
- }
-
- viable, score = slo_viability(slice_slos, nrp_slos)
-
- assert viable is False
- assert score == 0
-
- def test_slo_viability_multiple_metrics_partial_failure(self):
- """Test when one metric fails in a multi-metric comparison."""
- slice_slos = [
- {
- "metric-type": "one-way-bandwidth",
- "bound": 1000
- },
- {
- "metric-type": "one-way-delay-maximum",
- "bound": 10
- }
- ]
-
- nrp_slos = {
- "slos": [
- {
- "metric-type": "one-way-bandwidth",
- "bound": 1500 # OK
- },
- {
- "metric-type": "one-way-delay-maximum",
- "bound": 15 # NOT OK
- }
- ]
- }
-
- viable, score = slo_viability(slice_slos, nrp_slos)
-
- assert viable is False
- assert score == 0
-
- def test_slo_viability_flexibility_score_calculation(self):
- """Test flexibility score calculation."""
- slice_slos = [
- {
- "metric-type": "one-way-bandwidth",
- "bound": 1000
- }
- ]
-
- nrp_slos = {
- "slos": [
- {
- "metric-type": "one-way-bandwidth",
- "bound": 2000 # 100% better than requirement
- }
- ]
- }
-
- viable, score = slo_viability(slice_slos, nrp_slos)
-
- assert viable is True
- # Flexibility = (2000 - 1000) / 1000 = 1.0
- assert score == 1.0
-
- def test_slo_viability_empty_slos(self):
- """Test with empty SLO list."""
- slice_slos = []
- nrp_slos = {"slos": []}
-
- viable, score = slo_viability(slice_slos, nrp_slos)
-
- assert viable is True
- assert score == 0
-
- def test_slo_viability_no_matching_metrics(self):
- """Test when there are no matching metric types."""
- slice_slos = [
- {
- "metric-type": "one-way-bandwidth",
- "bound": 1000
- }
- ]
-
- nrp_slos = {
- "slos": [
- {
- "metric-type": "two-way-bandwidth",
- "bound": 1500
- }
- ]
- }
-
- viable, score = slo_viability(slice_slos, nrp_slos)
-
- # Should still return True as no metrics failed
- assert viable is True
- assert score == 0
-
- def test_slo_viability_packet_loss_maximum_type(self):
- """Test packet loss as maximum constraint type."""
- slice_slos = [
- {
- "metric-type": "one-way-packet-loss",
- "bound": 0.01 # 1% maximum acceptable
- }
- ]
-
- nrp_slos = {
- "slos": [
- {
- "metric-type": "one-way-packet-loss",
- "bound": 0.005 # 0.5% NRP loss
- }
- ]
- }
-
- viable, score = slo_viability(slice_slos, nrp_slos)
-
- assert viable is True
- assert score > 0
-
-
-class TestMapper:
- """Tests for mapper function."""
-
- def test_mapper_with_nrp_disabled_and_planner_disabled(self, app_context, sample_ietf_intent):
- """Test mapper when both NRP and Planner are disabled."""
- app_context.config = {
- "NRP_ENABLED": False,
- "PLANNER_ENABLED": False
- }
-
- payload = {
- "intent": sample_ietf_intent
- }
-
- result = mapper(payload)
-
- assert result == ([sample_ietf_intent], None)
-
- @patch('src.mapper.main.Planner')
- def test_mapper_with_planner_enabled(self, mock_planner_class, app_context, sample_ietf_intent):
- """Test mapper when Planner is enabled."""
- app_context.config = {
- "NRP_ENABLED": False,
- "PLANNER_ENABLED": True,
- "PLANNER_TYPE":"ENERGY"
- }
-
- mock_planner_instance = MagicMock()
- mock_planner_instance.planner.return_value = {"path": "node1->node2->node3"}
- mock_planner_class.return_value = mock_planner_instance
-
- payload = {
- "intent": sample_ietf_intent
- }
-
- result = mapper(payload)
-
- assert result == ([sample_ietf_intent], {"path": "node1->node2->node3"})
- mock_planner_instance.planner.assert_called_once_with(sample_ietf_intent, "ENERGY", is_update=False)
-
- @patch('src.mapper.main.realizer')
- def test_mapper_with_nrp_enabled_finds_best_nrp(self, mock_realizer, app_context, sample_ietf_intent, sample_nrp_view):
- """Test mapper with NRP enabled finds the best NRP."""
- app_context.config = {
- "NRP_ENABLED": True,
- "PLANNER_ENABLED": False,
- }
-
- mock_realizer.return_value = sample_nrp_view
-
- payload = {
- "intent": sample_ietf_intent
- }
- result = mapper(payload)
-
- # Verify realizer was called to READ NRP view
- assert mock_realizer.call_args_list[0] == call(None, True, "READ")
- assert result == ([sample_ietf_intent], None)
-
- @patch('src.mapper.main.realizer')
- def test_mapper_with_nrp_enabled_no_viable_candidates(self, mock_realizer, app_context, sample_ietf_intent):
- """Test mapper when no viable NRPs are found."""
- app_context.config = {
- "NRP_ENABLED": True,
- "PLANNER_ENABLED": False
- }
-
- # All NRPs are unavailable
- nrp_view = [
- {
- "id": "nrp-1",
- "available": False,
- "slices": [],
- "slos": [
- {
- "metric-type": "one-way-bandwidth",
- "bound": 500
- }
- ]
- }
- ]
-
- mock_realizer.return_value = nrp_view
-
- payload = {
- "intent": sample_ietf_intent
- }
-
- result = mapper(payload)
-
- assert result == ([sample_ietf_intent], None)
-
- @patch('src.mapper.main.realizer')
- def test_mapper_with_nrp_enabled_creates_new_nrp(self, mock_realizer, app_context, sample_ietf_intent):
- """Test mapper creates new NRP when no suitable candidate exists."""
- app_context.config = {
- "NRP_ENABLED": True,
- "PLANNER_ENABLED": False
- }
-
- # No viable NRPs
- nrp_view = []
-
- mock_realizer.side_effect = [nrp_view, None] # First call returns empty, second for CREATE
-
- payload = {
- "intent": sample_ietf_intent
- }
-
- result = mapper(payload)
-
- # Verify CREATE was called
- create_call = [c for c in mock_realizer.call_args_list if len(c[0]) > 2 and c[0][2] == "CREATE"]
- assert len(create_call) > 0
-
- @patch('src.mapper.main.realizer')
- def test_mapper_with_nrp_and_planner_both_enabled(self, mock_realizer, app_context, sample_ietf_intent, sample_nrp_view):
- """Test mapper when both NRP and Planner are enabled."""
- app_context.config = {
- "NRP_ENABLED": True,
- "PLANNER_ENABLED": True,
- "PLANNER_TYPE":"ENERGY"
- }
-
- mock_realizer.return_value = sample_nrp_view
-
- with patch('src.mapper.main.Planner') as mock_planner_class:
- mock_planner_instance = MagicMock()
- mock_planner_instance.planner.return_value = {"path": "optimized_path"}
- mock_planner_class.return_value = mock_planner_instance
-
- payload = {
- "intent": sample_ietf_intent
- }
- result = mapper(payload)
-
- # Planner should be called and return the result
- assert result == ([sample_ietf_intent],{"path": "optimized_path"})
-
- @patch('src.mapper.main.realizer')
- def test_mapper_updates_best_nrp_with_slice(self, mock_realizer, app_context, sample_ietf_intent, sample_nrp_view):
- """Test mapper updates best NRP with new slice."""
- app_context.config = {
- "NRP_ENABLED": True,
- "PLANNER_ENABLED": False
- }
-
- mock_realizer.return_value = sample_nrp_view
-
- payload = {
- "intent": sample_ietf_intent
- }
- result = mapper(payload)
-
- # Verify UPDATE was called
- update_calls = [c for c in mock_realizer.call_args_list if len(c[0]) > 2 and c[0][2] == "UPDATE"]
- assert len(update_calls) > 0
-
- @patch('src.mapper.main.realizer')
- def test_mapper_extracts_slos_correctly(self, mock_realizer, app_context, sample_ietf_intent):
- """Test that mapper correctly extracts SLOs from intent."""
- app_context.config = {
- "NRP_ENABLED": True,
- "PLANNER_ENABLED": False
- }
-
- mock_realizer.return_value = []
-
- payload = {
- "intent": sample_ietf_intent
- }
- mapper(payload)
-
- # Verify the function processed the intent
- assert mock_realizer.called
-
- @patch('src.mapper.main.logging')
- def test_mapper_logs_debug_info(self, mock_logging, app_context, sample_ietf_intent, sample_nrp_view):
- """Test mapper logs debug information."""
- app_context.config = {
- "NRP_ENABLED": True,
- "PLANNER_ENABLED": False
- }
-
- with patch('src.mapper.main.realizer') as mock_realizer:
- mock_realizer.return_value = sample_nrp_view
-
- payload = {
- "intent": sample_ietf_intent
- }
- mapper(payload)
-
- # Verify debug logging was called
- assert mock_logging.debug.called
-
-
-class TestMapperIntegration:
- """Integration tests for mapper functionality."""
-
- def test_mapper_complete_nrp_workflow(self, app_context, sample_ietf_intent, sample_nrp_view):
- """Test complete NRP mapping workflow."""
- app_context.config = {
- "NRP_ENABLED": True,
- "PLANNER_ENABLED": False
- }
-
- with patch('src.mapper.main.realizer') as mock_realizer:
- mock_realizer.return_value = sample_nrp_view
-
- payload = {
- "intent": sample_ietf_intent
- }
- result = mapper(payload)
-
- # Verify the workflow sequence
- assert mock_realizer.call_count >= 1
- first_call = mock_realizer.call_args_list[0]
- assert first_call[0][1] is True # need_nrp parameter
- assert first_call[0][2] == "READ" # READ operation
-
- def test_mapper_complete_planner_workflow(self, app_context, sample_ietf_intent):
- """Test complete Planner workflow."""
- app_context.config = {
- "NRP_ENABLED": False,
- "PLANNER_ENABLED": True,
- "PLANNER_TYPE":"ENERGY"
- }
-
- expected_path = {
- "path": "node1->node2->node3",
- "cost": 10,
- "latency": 5
- }
-
- with patch('src.mapper.main.Planner') as mock_planner_class:
- mock_planner_instance = MagicMock()
- mock_planner_instance.planner.return_value = expected_path
- mock_planner_class.return_value = mock_planner_instance
-
- payload = {
- "intent": sample_ietf_intent
- }
- result = mapper(payload)
-
- assert result == ([sample_ietf_intent],expected_path)
- mock_planner_instance.planner.assert_called_once()
-
- def test_mapper_with_invalid_nrp_response(self, app_context, sample_ietf_intent):
- """Test mapper behavior with invalid NRP response."""
- app_context.config = {
- "NRP_ENABLED": True,
- "PLANNER_ENABLED": False
- }
-
- # Invalid NRP without expected fields
- invalid_nrp = {
- "id": "nrp-invalid"
- # Missing 'available' and 'slos' fields
- }
-
- with patch('src.mapper.main.realizer') as mock_realizer:
- mock_realizer.return_value = [invalid_nrp]
-
- # Should handle gracefully
- try:
- payload = {
- "intent": sample_ietf_intent
- }
- result = mapper(payload)
- except (KeyError, TypeError):
- # Expected to fail gracefully
- pass
-
- def test_mapper_with_missing_slos_in_intent(self, app_context):
- """Test mapper behavior when intent has no SLOs."""
- app_context.config = {
- "NRP_ENABLED": True,
- "PLANNER_ENABLED": False
- }
-
- invalid_intent = {
- "ietf-network-slice-service:network-slice-services": {
- "slice-service": [{
- "id": "slice-1"
- }],
- "slo-sle-templates": {
- "slo-sle-template": [{
- "id": "profile1",
- "slo-policy": {
- # No metric-bound key
- }
- }]
- }
- }
- }
-
- payload = {
- "intent": invalid_intent
- }
- try:
- mapper(payload)
- except (KeyError, TypeError):
- # Expected behavior
- pass
-
-
-class TestSloViabilityEdgeCases:
- """Edge case tests for slo_viability function."""
-
- def test_slo_viability_with_zero_bound(self):
- """Test handling of zero bounds in SLO."""
- slice_slos = [
- {
- "metric-type": "one-way-bandwidth",
- "bound": 0
- }
- ]
-
- nrp_slos = {
- "slos": [
- {
- "metric-type": "one-way-bandwidth",
- "bound": 100
- }
- ]
- }
-
- # Should handle zero division gracefully or fail as expected
- try:
- viable, score = slo_viability(slice_slos, nrp_slos)
- except (ZeroDivisionError, ValueError):
- pass
-
- def test_slo_viability_with_very_large_bounds(self):
- """Test handling of very large SLO bounds."""
- slice_slos = [
- {
- "metric-type": "one-way-bandwidth",
- "bound": 1e10
- }
- ]
-
- nrp_slos = {
- "slos": [
- {
- "metric-type": "one-way-bandwidth",
- "bound": 2e10
- }
- ]
- }
-
- viable, score = slo_viability(slice_slos, nrp_slos)
-
- assert viable is True
- assert isinstance(score, (int, float))
-
- def test_slo_viability_all_delay_types(self):
- """Test handling of all delay metric t ypes."""
- delay_types = [
- "one-way-delay-maximum",
- "two-way-delay-maximum",
- "one-way-delay-percentile",
- "two-way-delay-percentile",
- "one-way-delay-variation-maximum",
- "two-way-delay-variation-maximum"
- ]
-
- for delay_type in delay_types:
- slice_slos = [{"metric-type": delay_type, "bound": 10}]
- nrp_slos = {"slos": [{"metric-type": delay_type, "bound": 8}]}
-
- viable, score = slo_viability(slice_slos, nrp_slos)
-
- assert viable is True
- assert score >= 0
\ No newline at end of file
+# Copyright 2022-2026 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/)
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# This file is an original contribution from Telefonica Innovación Digital S.L.
+
+import pytest
+import logging
+from unittest.mock import patch, MagicMock, call
+from flask import Flask
+from src.mapper.main import mapper
+from src.mapper.slo_viability import slo_viability
+
+
+@pytest.fixture
+def sample_ietf_intent():
+ """Fixture providing sample IETF network slice intent."""
+ return {
+ "ietf-network-slice-service:network-slice-services": {
+ "slice-service": [{
+ "id": "slice-service-12345",
+ "description": "Test network slice",
+ "service-tags": {"tag-type": {"value": "L2VPN"}}
+ }],
+ "slo-sle-templates": {
+ "slo-sle-template": [{
+ "id": "profile1",
+ "slo-policy": {
+ "metric-bound": [
+ {
+ "metric-type": "one-way-bandwidth",
+ "metric-unit": "kbps",
+ "bound": 1000
+ },
+ {
+ "metric-type": "one-way-delay-maximum",
+ "metric-unit": "milliseconds",
+ "bound": 10
+ }
+ ]
+ }
+ }]
+ }
+ }
+ }
+
+
+@pytest.fixture
+def sample_nrp_view():
+ """Fixture providing sample NRP view."""
+ return [
+ {
+ "id": "nrp-1",
+ "available": True,
+ "slices": [],
+ "slos": [
+ {
+ "metric-type": "one-way-bandwidth",
+ "bound": 1500
+ },
+ {
+ "metric-type": "one-way-delay-maximum",
+ "bound": 8
+ }
+ ]
+ },
+ {
+ "id": "nrp-2",
+ "available": True,
+ "slices": [],
+ "slos": [
+ {
+ "metric-type": "one-way-bandwidth",
+ "bound": 500
+ },
+ {
+ "metric-type": "one-way-delay-maximum",
+ "bound": 15
+ }
+ ]
+ },
+ {
+ "id": "nrp-3",
+ "available": False,
+ "slices": [],
+ "slos": [
+ {
+ "metric-type": "one-way-bandwidth",
+ "bound": 2000
+ },
+ {
+ "metric-type": "one-way-delay-maximum",
+ "bound": 5
+ }
+ ]
+ }
+ ]
+
+
+@pytest.fixture
+def mock_app():
+ """Fixture providing mock Flask app context."""
+ app = Flask(__name__)
+ app.config = {
+ "NRP_ENABLED": False,
+ "PLANNER_ENABLED": False,
+ "SERVER_NAME": "localhost",
+ "APPLICATION_ROOT": "/",
+ "PREFERRED_URL_SCHEME": "http"
+ }
+ return app
+
+
+@pytest.fixture
+def app_context(mock_app):
+ """Fixture providing Flask application context."""
+ with mock_app.app_context():
+ yield mock_app
+
+
+class TestSloViability:
+ """Tests for slo_viability function."""
+
+ def test_slo_viability_meets_all_requirements(self):
+ """Test when NRP meets all SLO requirements."""
+ slice_slos = [
+ {
+ "metric-type": "one-way-bandwidth",
+ "bound": 1000
+ },
+ {
+ "metric-type": "one-way-delay-maximum",
+ "bound": 10
+ }
+ ]
+
+ nrp_slos = {
+ "slos": [
+ {
+ "metric-type": "one-way-bandwidth",
+ "bound": 1500
+ },
+ {
+ "metric-type": "one-way-delay-maximum",
+ "bound": 8
+ }
+ ]
+ }
+
+ viable, score = slo_viability(slice_slos, nrp_slos)
+
+ assert viable is True
+ assert score > 0
+
+ def test_slo_viability_fails_bandwidth_minimum(self):
+ """Test when NRP doesn't meet minimum bandwidth requirement."""
+ slice_slos = [
+ {
+ "metric-type": "one-way-bandwidth",
+ "bound": 1000
+ }
+ ]
+
+ nrp_slos = {
+ "slos": [
+ {
+ "metric-type": "one-way-bandwidth",
+ "bound": 500 # Less than required
+ }
+ ]
+ }
+
+ viable, score = slo_viability(slice_slos, nrp_slos)
+
+ assert viable is False
+ assert score == 0
+
+ def test_slo_viability_fails_delay_maximum(self):
+ """Test when NRP doesn't meet maximum delay requirement."""
+ slice_slos = [
+ {
+ "metric-type": "one-way-delay-maximum",
+ "bound": 10
+ }
+ ]
+
+ nrp_slos = {
+ "slos": [
+ {
+ "metric-type": "one-way-delay-maximum",
+ "bound": 15 # Greater than maximum allowed
+ }
+ ]
+ }
+
+ viable, score = slo_viability(slice_slos, nrp_slos)
+
+ assert viable is False
+ assert score == 0
+
+ def test_slo_viability_multiple_metrics_partial_failure(self):
+ """Test when one metric fails in a multi-metric comparison."""
+ slice_slos = [
+ {
+ "metric-type": "one-way-bandwidth",
+ "bound": 1000
+ },
+ {
+ "metric-type": "one-way-delay-maximum",
+ "bound": 10
+ }
+ ]
+
+ nrp_slos = {
+ "slos": [
+ {
+ "metric-type": "one-way-bandwidth",
+ "bound": 1500 # OK
+ },
+ {
+ "metric-type": "one-way-delay-maximum",
+ "bound": 15 # NOT OK
+ }
+ ]
+ }
+
+ viable, score = slo_viability(slice_slos, nrp_slos)
+
+ assert viable is False
+ assert score == 0
+
+ def test_slo_viability_flexibility_score_calculation(self):
+ """Test flexibility score calculation."""
+ slice_slos = [
+ {
+ "metric-type": "one-way-bandwidth",
+ "bound": 1000
+ }
+ ]
+
+ nrp_slos = {
+ "slos": [
+ {
+ "metric-type": "one-way-bandwidth",
+ "bound": 2000 # 100% better than requirement
+ }
+ ]
+ }
+
+ viable, score = slo_viability(slice_slos, nrp_slos)
+
+ assert viable is True
+ # Flexibility = (2000 - 1000) / 1000 = 1.0
+ assert score == 1.0
+
+ def test_slo_viability_empty_slos(self):
+ """Test with empty SLO list."""
+ slice_slos = []
+ nrp_slos = {"slos": []}
+
+ viable, score = slo_viability(slice_slos, nrp_slos)
+
+ assert viable is True
+ assert score == 0
+
+ def test_slo_viability_no_matching_metrics(self):
+ """Test when there are no matching metric types."""
+ slice_slos = [
+ {
+ "metric-type": "one-way-bandwidth",
+ "bound": 1000
+ }
+ ]
+
+ nrp_slos = {
+ "slos": [
+ {
+ "metric-type": "two-way-bandwidth",
+ "bound": 1500
+ }
+ ]
+ }
+
+ viable, score = slo_viability(slice_slos, nrp_slos)
+
+ # Should still return True as no metrics failed
+ assert viable is True
+ assert score == 0
+
+ def test_slo_viability_packet_loss_maximum_type(self):
+ """Test packet loss as maximum constraint type."""
+ slice_slos = [
+ {
+ "metric-type": "one-way-packet-loss",
+ "bound": 0.01 # 1% maximum acceptable
+ }
+ ]
+
+ nrp_slos = {
+ "slos": [
+ {
+ "metric-type": "one-way-packet-loss",
+ "bound": 0.005 # 0.5% NRP loss
+ }
+ ]
+ }
+
+ viable, score = slo_viability(slice_slos, nrp_slos)
+
+ assert viable is True
+ assert score > 0
+
+
+class TestMapper:
+ """Tests for mapper function."""
+
+ def test_mapper_with_nrp_disabled_and_planner_disabled(self, app_context, sample_ietf_intent):
+ """Test mapper when both NRP and Planner are disabled."""
+ app_context.config = {
+ "NRP_ENABLED": False,
+ "PLANNER_ENABLED": False
+ }
+
+ payload = {
+ "intent": sample_ietf_intent
+ }
+
+ result = mapper(payload)
+
+ assert result == ([sample_ietf_intent], None)
+
+ @patch('src.mapper.main.Planner')
+ def test_mapper_with_planner_enabled(self, mock_planner_class, app_context, sample_ietf_intent):
+ """Test mapper when Planner is enabled."""
+ app_context.config = {
+ "NRP_ENABLED": False,
+ "PLANNER_ENABLED": True,
+ "PLANNER_TYPE":"ENERGY"
+ }
+
+ mock_planner_instance = MagicMock()
+ mock_planner_instance.planner.return_value = {"path": "node1->node2->node3"}
+ mock_planner_class.return_value = mock_planner_instance
+
+ payload = {
+ "intent": sample_ietf_intent
+ }
+
+ result = mapper(payload)
+
+ assert result == ([sample_ietf_intent], {"path": "node1->node2->node3"})
+ mock_planner_instance.planner.assert_called_once_with(sample_ietf_intent, "ENERGY", is_update=False)
+
+ @patch('src.mapper.main.realizer')
+ def test_mapper_with_nrp_enabled_finds_best_nrp(self, mock_realizer, app_context, sample_ietf_intent, sample_nrp_view):
+ """Test mapper with NRP enabled finds the best NRP."""
+ app_context.config = {
+ "NRP_ENABLED": True,
+ "PLANNER_ENABLED": False,
+ }
+
+ mock_realizer.return_value = sample_nrp_view
+
+ payload = {
+ "intent": sample_ietf_intent
+ }
+ result = mapper(payload)
+
+ # Verify realizer was called to READ NRP view
+ assert mock_realizer.call_args_list[0] == call(None, True, "READ")
+ assert result == ([sample_ietf_intent], None)
+
+ @patch('src.mapper.main.realizer')
+ def test_mapper_with_nrp_enabled_no_viable_candidates(self, mock_realizer, app_context, sample_ietf_intent):
+ """Test mapper when no viable NRPs are found."""
+ app_context.config = {
+ "NRP_ENABLED": True,
+ "PLANNER_ENABLED": False
+ }
+
+ # All NRPs are unavailable
+ nrp_view = [
+ {
+ "id": "nrp-1",
+ "available": False,
+ "slices": [],
+ "slos": [
+ {
+ "metric-type": "one-way-bandwidth",
+ "bound": 500
+ }
+ ]
+ }
+ ]
+
+ mock_realizer.return_value = nrp_view
+
+ payload = {
+ "intent": sample_ietf_intent
+ }
+
+ result = mapper(payload)
+
+ assert result == ([sample_ietf_intent], None)
+
+ @patch('src.mapper.main.realizer')
+ def test_mapper_with_nrp_enabled_creates_new_nrp(self, mock_realizer, app_context, sample_ietf_intent):
+ """Test mapper creates new NRP when no suitable candidate exists."""
+ app_context.config = {
+ "NRP_ENABLED": True,
+ "PLANNER_ENABLED": False
+ }
+
+ # No viable NRPs
+ nrp_view = []
+
+ mock_realizer.side_effect = [nrp_view, None] # First call returns empty, second for CREATE
+
+ payload = {
+ "intent": sample_ietf_intent
+ }
+
+ result = mapper(payload)
+
+ # Verify CREATE was called
+ create_call = [c for c in mock_realizer.call_args_list if len(c[0]) > 2 and c[0][2] == "CREATE"]
+ assert len(create_call) > 0
+
+ @patch('src.mapper.main.realizer')
+ def test_mapper_with_nrp_and_planner_both_enabled(self, mock_realizer, app_context, sample_ietf_intent, sample_nrp_view):
+ """Test mapper when both NRP and Planner are enabled."""
+ app_context.config = {
+ "NRP_ENABLED": True,
+ "PLANNER_ENABLED": True,
+ "PLANNER_TYPE":"ENERGY"
+ }
+
+ mock_realizer.return_value = sample_nrp_view
+
+ with patch('src.mapper.main.Planner') as mock_planner_class:
+ mock_planner_instance = MagicMock()
+ mock_planner_instance.planner.return_value = {"path": "optimized_path"}
+ mock_planner_class.return_value = mock_planner_instance
+
+ payload = {
+ "intent": sample_ietf_intent
+ }
+ result = mapper(payload)
+
+ # Planner should be called and return the result
+ assert result == ([sample_ietf_intent],{"path": "optimized_path"})
+
+ @patch('src.mapper.main.realizer')
+ def test_mapper_updates_best_nrp_with_slice(self, mock_realizer, app_context, sample_ietf_intent, sample_nrp_view):
+ """Test mapper updates best NRP with new slice."""
+ app_context.config = {
+ "NRP_ENABLED": True,
+ "PLANNER_ENABLED": False
+ }
+
+ mock_realizer.return_value = sample_nrp_view
+
+ payload = {
+ "intent": sample_ietf_intent
+ }
+ result = mapper(payload)
+
+ # Verify UPDATE was called
+ update_calls = [c for c in mock_realizer.call_args_list if len(c[0]) > 2 and c[0][2] == "UPDATE"]
+ assert len(update_calls) > 0
+
+ @patch('src.mapper.main.realizer')
+ def test_mapper_extracts_slos_correctly(self, mock_realizer, app_context, sample_ietf_intent):
+ """Test that mapper correctly extracts SLOs from intent."""
+ app_context.config = {
+ "NRP_ENABLED": True,
+ "PLANNER_ENABLED": False
+ }
+
+ mock_realizer.return_value = []
+
+ payload = {
+ "intent": sample_ietf_intent
+ }
+ mapper(payload)
+
+ # Verify the function processed the intent
+ assert mock_realizer.called
+
+ @patch('src.mapper.main.logging')
+ def test_mapper_logs_debug_info(self, mock_logging, app_context, sample_ietf_intent, sample_nrp_view):
+ """Test mapper logs debug information."""
+ app_context.config = {
+ "NRP_ENABLED": True,
+ "PLANNER_ENABLED": False
+ }
+
+ with patch('src.mapper.main.realizer') as mock_realizer:
+ mock_realizer.return_value = sample_nrp_view
+
+ payload = {
+ "intent": sample_ietf_intent
+ }
+ mapper(payload)
+
+ # Verify debug logging was called
+ assert mock_logging.debug.called
+
+
+class TestMapperIntegration:
+ """Integration tests for mapper functionality."""
+
+ def test_mapper_complete_nrp_workflow(self, app_context, sample_ietf_intent, sample_nrp_view):
+ """Test complete NRP mapping workflow."""
+ app_context.config = {
+ "NRP_ENABLED": True,
+ "PLANNER_ENABLED": False
+ }
+
+ with patch('src.mapper.main.realizer') as mock_realizer:
+ mock_realizer.return_value = sample_nrp_view
+
+ payload = {
+ "intent": sample_ietf_intent
+ }
+ result = mapper(payload)
+
+ # Verify the workflow sequence
+ assert mock_realizer.call_count >= 1
+ first_call = mock_realizer.call_args_list[0]
+ assert first_call[0][1] is True # need_nrp parameter
+ assert first_call[0][2] == "READ" # READ operation
+
+ def test_mapper_complete_planner_workflow(self, app_context, sample_ietf_intent):
+ """Test complete Planner workflow."""
+ app_context.config = {
+ "NRP_ENABLED": False,
+ "PLANNER_ENABLED": True,
+ "PLANNER_TYPE":"ENERGY"
+ }
+
+ expected_path = {
+ "path": "node1->node2->node3",
+ "cost": 10,
+ "latency": 5
+ }
+
+ with patch('src.mapper.main.Planner') as mock_planner_class:
+ mock_planner_instance = MagicMock()
+ mock_planner_instance.planner.return_value = expected_path
+ mock_planner_class.return_value = mock_planner_instance
+
+ payload = {
+ "intent": sample_ietf_intent
+ }
+ result = mapper(payload)
+
+ assert result == ([sample_ietf_intent],expected_path)
+ mock_planner_instance.planner.assert_called_once()
+
+ def test_mapper_with_invalid_nrp_response(self, app_context, sample_ietf_intent):
+ """Test mapper behavior with invalid NRP response."""
+ app_context.config = {
+ "NRP_ENABLED": True,
+ "PLANNER_ENABLED": False
+ }
+
+ # Invalid NRP without expected fields
+ invalid_nrp = {
+ "id": "nrp-invalid"
+ # Missing 'available' and 'slos' fields
+ }
+
+ with patch('src.mapper.main.realizer') as mock_realizer:
+ mock_realizer.return_value = [invalid_nrp]
+
+ # Should handle gracefully
+ try:
+ payload = {
+ "intent": sample_ietf_intent
+ }
+ result = mapper(payload)
+ except (KeyError, TypeError):
+ # Expected to fail gracefully
+ pass
+
+ def test_mapper_with_missing_slos_in_intent(self, app_context):
+ """Test mapper behavior when intent has no SLOs."""
+ app_context.config = {
+ "NRP_ENABLED": True,
+ "PLANNER_ENABLED": False
+ }
+
+ invalid_intent = {
+ "ietf-network-slice-service:network-slice-services": {
+ "slice-service": [{
+ "id": "slice-1"
+ }],
+ "slo-sle-templates": {
+ "slo-sle-template": [{
+ "id": "profile1",
+ "slo-policy": {
+ # No metric-bound key
+ }
+ }]
+ }
+ }
+ }
+
+ payload = {
+ "intent": invalid_intent
+ }
+ try:
+ mapper(payload)
+ except (KeyError, TypeError):
+ # Expected behavior
+ pass
+
+
+class TestSloViabilityEdgeCases:
+ """Edge case tests for slo_viability function."""
+
+ def test_slo_viability_with_zero_bound(self):
+ """Test handling of zero bounds in SLO."""
+ slice_slos = [
+ {
+ "metric-type": "one-way-bandwidth",
+ "bound": 0
+ }
+ ]
+
+ nrp_slos = {
+ "slos": [
+ {
+ "metric-type": "one-way-bandwidth",
+ "bound": 100
+ }
+ ]
+ }
+
+ # Should handle zero division gracefully or fail as expected
+ try:
+ viable, score = slo_viability(slice_slos, nrp_slos)
+ except (ZeroDivisionError, ValueError):
+ pass
+
+ def test_slo_viability_with_very_large_bounds(self):
+ """Test handling of very large SLO bounds."""
+ slice_slos = [
+ {
+ "metric-type": "one-way-bandwidth",
+ "bound": 1e10
+ }
+ ]
+
+ nrp_slos = {
+ "slos": [
+ {
+ "metric-type": "one-way-bandwidth",
+ "bound": 2e10
+ }
+ ]
+ }
+
+ viable, score = slo_viability(slice_slos, nrp_slos)
+
+ assert viable is True
+ assert isinstance(score, (int, float))
+
+ def test_slo_viability_all_delay_types(self):
+ """Test handling of all delay metric types."""
+ delay_types = [
+ "one-way-delay-maximum",
+ "two-way-delay-maximum",
+ "one-way-delay-percentile",
+ "two-way-delay-percentile",
+ "one-way-delay-variation-maximum",
+ "two-way-delay-variation-maximum"
+ ]
+
+ for delay_type in delay_types:
+ slice_slos = [{"metric-type": delay_type, "bound": 10}]
+ nrp_slos = {"slos": [{"metric-type": delay_type, "bound": 8}]}
+
+ viable, score = slo_viability(slice_slos, nrp_slos)
+
+ assert viable is True
+ assert score >= 0
+
+
+class TestMapperSubmodules:
+ """Tests for mapper submodules: extract_sdp_info, process_connectivity, aggregate_monitoring, get_service_template."""
+
+ def test_extract_sdp_info(self):
+ from src.mapper.extract_sdp_info import extract_sdp_info
+
+ slice_service = {
+ "sdps": {
+ "sdp": [
+ {
+ "id": "sdp-1",
+ "sdp-ip-address": "10.0.0.1",
+ "service-match-criteria": {
+ "match-criterion": [{"target-connection-group-id": "cg-1"}]
+ }
+ }
+ ]
+ }
+ }
+ sdp, mc = extract_sdp_info("sdp-1", slice_service, "cg-1", None)
+ assert sdp["id"] == "sdp-1"
+ assert mc is not None
+
+ def test_process_connectivity(self):
+ from src.mapper.process_connnectivity import process_connectivity
+
+ slice_service = {
+ "sdps": {
+ "sdp": [
+ {"id": "sdp-1", "sdp-ip-address": "10.0.0.1"},
+ {"id": "sdp-2", "sdp-ip-address": "10.0.0.2"}
+ ]
+ }
+ }
+ conn_construct = {"a2a-sdp": ["sdp-1", "sdp-2"]}
+ res = process_connectivity("cg-1", "ietf-vpn-common:any-to-any", conn_construct, "cc-1", slice_service)
+ assert isinstance(res, list)
+ assert len(res) == 2
+
+ def test_aggregate_monitoring(self, flask_app):
+ from src.mapper.aggregate_monitoring import aggregate_monitoring
+
+ flask_app.config["TELEMETRY_CACHE"] = {
+ "slice-1": {
+ "A-B": {"bandwidth": 1000, "latency": 5}
+ }
+ }
+ slo_template = {
+ "slo-policy": {
+ "metric-bound": [
+ {"metric-type": "ietf-network-slice-service:two-way-bandwidth", "bound": 500},
+ {"metric-type": "ietf-network-slice-service:two-way-delay-maximum", "bound": 10}
+ ]
+ }
+ }
+ with flask_app.app_context(), patch("src.mapper.aggregate_monitoring.upsert_telemetry"):
+ aggregate_monitoring("slice-1", slo_template)
+
+ def test_get_service_template_and_get_template(self):
+ from src.mapper.get_service_template import get_service_template
+ from src.mapper.get_template import get_template
+
+ available = [{"id": "tmpl-1", "slo-policy": {}}]
+ elem = {"slo-sle-template": "tmpl-1"}
+ res = get_service_template(elem, available)
+ assert res == {"id": "tmpl-1", "slo-policy": {}}
+
+ tmpl = get_template("tmpl-1", available)
+ assert tmpl["id"] == "tmpl-1"
+
+ def test_process_connectivity_hub_spoke_and_p2p(self):
+ from src.mapper.process_connnectivity import process_connectivity
+
+ slice_service = {
+ "sdps": {
+ "sdp": [
+ {"id": "sdp-1", "sdp-ip-address": "10.0.0.1"},
+ {"id": "sdp-2", "sdp-ip-address": "10.0.0.2"}
+ ]
+ }
+ }
+
+ # Hub-Spoke
+ hs_construct = {"p2mp-sender-sdp": "sdp-1", "p2mp-receiver-sdp": ["sdp-2"]}
+ res_hs = process_connectivity("cg-1", "ietf-vpn-common:hub-spoke", hs_construct, "cc-1", slice_service)
+ assert len(res_hs) == 2
+ assert res_hs[0]["type"] == "sender"
+ assert res_hs[1]["type"] == "receiver"
+
+ # Point-to-Point
+ p2p_construct = {"p2p-sender-sdp": "sdp-1", "p2p-receiver-sdp": "sdp-2"}
+ res_p2p = process_connectivity("cg-1", "point-to-point", p2p_construct, "cc-1", slice_service)
+ assert len(res_p2p) == 2
+ assert res_p2p[0]["type"] == "sender"
+ assert res_p2p[1]["type"] == "receiver"
+
+
+class TestMapperMonitoring:
+ """Comprehensive tests for MONITOR action in mapper and aggregate_monitoring."""
+
+ def test_mapper_monitor_action(self, flask_app):
+ payload = {
+ "slice_id": "slice-mon-100",
+ "slo_sle_template": {
+ "slo-policy": {
+ "metric-bound": [
+ {"metric-type": "ietf-network-slice-service:two-way-bandwidth", "bound": 100},
+ {"metric-type": "ietf-network-slice-service:two-way-delay-maximum", "bound": 20}
+ ]
+ }
+ }
+ }
+ flask_app.config["TELEMETRY_CACHE"]["slice-mon-100"] = {
+ "link-1": {"bandwidth": 150, "latency": 15}
+ }
+
+ with flask_app.app_context(), \
+ patch("src.mapper.aggregate_monitoring.upsert_telemetry") as mock_upsert:
+ metrics = mapper(payload, action="MONITOR")
+ assert metrics["slice_id"] == "slice-mon-100"
+ assert metrics["slo_sle_compliance"]["is_compliant"] is True
+ assert metrics["slo_sle_compliance"]["violated_metrics"] == []
+ mock_upsert.assert_called_once()
+
+ def test_aggregate_monitoring_latency_and_bandwidth_violations(self, flask_app):
+ from src.mapper.aggregate_monitoring import aggregate_monitoring
+
+ slice_id = "slice-mon-violations"
+ slo_template = {
+ "slo-policy": {
+ "metric-bound": [
+ {"metric-type": "ietf-network-slice-service:two-way-bandwidth", "bound": 500},
+ {"metric-type": "ietf-network-slice-service:two-way-delay-maximum", "bound": 10}
+ ]
+ }
+ }
+
+ # Case 1: Latency violation only
+ flask_app.config["TELEMETRY_CACHE"][slice_id] = {
+ "link-1": {"bandwidth": 600, "latency": 15}
+ }
+ with flask_app.app_context(), patch("src.mapper.aggregate_monitoring.upsert_telemetry"):
+ res_lat = aggregate_monitoring(slice_id, slo_template)
+ assert res_lat["slo_sle_compliance"]["is_compliant"] is False
+ assert res_lat["slo_sle_compliance"]["violated_metrics"] == ["latency"]
+
+ # Case 2: Bandwidth violation only
+ flask_app.config["TELEMETRY_CACHE"][slice_id] = {
+ "link-1": {"bandwidth": 400, "latency": 5}
+ }
+ with flask_app.app_context(), patch("src.mapper.aggregate_monitoring.upsert_telemetry"):
+ res_bw = aggregate_monitoring(slice_id, slo_template)
+ assert res_bw["slo_sle_compliance"]["is_compliant"] is False
+ assert res_bw["slo_sle_compliance"]["violated_metrics"] == ["bandwidth"]
+
+ # Case 3: Both violated
+ flask_app.config["TELEMETRY_CACHE"][slice_id] = {
+ "link-1": {"bandwidth": 300, "latency": 25}
+ }
+ with flask_app.app_context(), patch("src.mapper.aggregate_monitoring.upsert_telemetry"):
+ res_both = aggregate_monitoring(slice_id, slo_template)
+ assert res_both["slo_sle_compliance"]["is_compliant"] is False
+ assert set(res_both["slo_sle_compliance"]["violated_metrics"]) == {"latency", "bandwidth"}
+
+ def test_aggregate_monitoring_empty_links_error(self, flask_app):
+ from src.mapper.aggregate_monitoring import aggregate_monitoring
+
+ flask_app.config["TELEMETRY_CACHE"]["slice-empty"] = {}
+ with flask_app.app_context():
+ with pytest.raises(Exception, match="No telemetry data available for slice"):
+ aggregate_monitoring("slice-empty", {})
+
+ def test_aggregate_monitoring_cache_not_initialized_error(self, flask_app):
+ from src.mapper.aggregate_monitoring import aggregate_monitoring
+
+ flask_app.config["TELEMETRY_CACHE"].pop("slice-missing", None)
+ with flask_app.app_context():
+ with pytest.raises(Exception, match="Telemetry cache for slice .* is not initialized"):
+ aggregate_monitoring("slice-missing", {})
+
+ def test_mapper_connection_groups_and_template_overrides(self, flask_app):
+ from src.mapper.main import mapper
+
+ flask_app.config["DUMMY_MODE"] = False
+
+ intent_full = {
+ "ietf-network-slice-service:network-slice-services": {
+ "slo-sle-templates": {
+ "slo-sle-template": [
+ {"id": "service-tmpl", "bound": 10},
+ {"id": "group-tmpl", "bound": 20},
+ {"id": "construct-tmpl", "bound": 30}
+ ]
+ },
+ "slice-service": [
+ {
+ "id": "slice-full-1",
+ "slo-sle-template": "service-tmpl",
+ "service-tags": {
+ "tag-type": {
+ "ietf-network-slice-service:service": {
+ "tag-type-value": ["L2"]
+ }
+ }
+ },
+ "sdps": {
+ "sdp": [
+ {"id": "sdp-1", "node-id": "N1"},
+ {"id": "sdp-2", "node-id": "N2"}
+ ]
+ },
+ "connection-groups": {
+ "connection-group": [
+ {
+ "id": "cg-1",
+ "slo-sle-template": "group-tmpl",
+ "connectivity-type": "point-to-point",
+ "connectivity-construct": [
+ {
+ "id": "cc-1",
+ "slo-sle-template": "construct-tmpl",
+ "p2p-sender-sdp": "sdp-1",
+ "p2p-receiver-sdp": "sdp-2"
+ },
+ {
+ "id": "cc-2",
+ "p2p-sender-sdp": "sdp-1",
+ "p2p-receiver-sdp": "sdp-2"
+ }
+ ]
+ }
+ ]
+ }
+ }
+ ]
+ }
+ }
+
+ payload = {"intent": intent_full, "is_update": False}
+
+ with flask_app.app_context(), \
+ patch("src.mapper.main.get_data_store", return_value=None), \
+ patch("src.mapper.main.normalize_libyang_data", side_effect=lambda x: x), \
+ patch("src.mapper.main.save_data") as mock_save_data:
+ services, rules = mapper(payload, controller_type="RESTCONF")
+ assert len(services) == 1
+ assert services[0]["id"] == "slice-full-1-cg-1-cc-1"
+ assert services[0]["template"] == {"id": "construct-tmpl", "bound": 30}
+ assert services[0]["connectivity_type"] == "point-to-point"
+ mock_save_data.assert_called_once()
+
+ def test_mapper_non_p2p_and_group_template_inheritance(self, flask_app):
+ from src.mapper.main import mapper
+
+ flask_app.config["DUMMY_MODE"] = True
+
+ intent_non_p2p = {
+ "ietf-network-slice-service:network-slice-services": {
+ "slo-sle-templates": {
+ "slo-sle-template": [
+ {"id": "service-tmpl", "bound": 10},
+ {"id": "group-tmpl", "bound": 20}
+ ]
+ },
+ "slice-service": [
+ {
+ "id": "slice-non-p2p",
+ "slo-sle-template": "service-tmpl",
+ "sdps": {
+ "sdp": [
+ {"id": "sdp-1", "node-id": "N1"},
+ {"id": "sdp-2", "node-id": "N2"}
+ ]
+ },
+ "connection-groups": {
+ "connection-group": [
+ {
+ "id": "cg-1",
+ "slo-sle-template": "group-tmpl",
+ "connectivity-type": "ietf-vpn-common:any-to-any",
+ "connectivity-construct": [
+ {
+ "id": "cc-1",
+ "a2a-sdp": ["sdp-1", "sdp-2"]
+ },
+ {
+ "id": "cc-2",
+ "a2a-sdp": ["sdp-1", "sdp-2"]
+ }
+ ]
+ }
+ ]
+ }
+ }
+ ]
+ }
+ }
+
+ payload = {"intent": intent_non_p2p}
+
+ with flask_app.app_context(), \
+ patch("src.mapper.main.get_data_store", return_value=None), \
+ patch("src.mapper.main.normalize_libyang_data", side_effect=lambda x: x):
+ services, _ = mapper(payload, controller_type="RESTCONF")
+ assert len(services) == 2
+ assert services[0]["template"] == {"id": "group-tmpl", "bound": 20}
+ assert services[1]["template"] == {"id": "group-tmpl", "bound": 20}
+
+ def test_mapper_empty_sdps_skipped(self, flask_app):
+ from src.mapper.main import mapper
+
+ flask_app.config["DUMMY_MODE"] = True
+
+ intent_empty_sdp = {
+ "ietf-network-slice-service:network-slice-services": {
+ "slice-service": [
+ {
+ "id": "slice-empty-sdps",
+ "connection-groups": {
+ "connection-group": [
+ {
+ "id": "cg-1",
+ "connectivity-type": "point-to-point",
+ "connectivity-construct": [
+ {"id": "cc-1"}
+ ]
+ }
+ ]
+ }
+ }
+ ]
+ }
+ }
+
+ payload = {"intent": intent_empty_sdp}
+
+ with flask_app.app_context(), \
+ patch("src.mapper.main.get_data_store", return_value=None), \
+ patch("src.mapper.main.process_connectivity", return_value=[]):
+ services, _ = mapper(payload, controller_type="RESTCONF")
+ assert services == []
\ No newline at end of file
diff --git a/src/tests/test_namespaces.py b/src/tests/test_namespaces.py
new file mode 100644
index 0000000000000000000000000000000000000000..166fc4c266df0f93036447aad18a5b591c7029cd
--- /dev/null
+++ b/src/tests/test_namespaces.py
@@ -0,0 +1,461 @@
+# Copyright 2022-2026 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/)
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# This file is an original contribution from Telefonica Innovación Digital S.L.
+
+import io
+import json
+import pytest
+from unittest.mock import patch, MagicMock
+
+
+# =============================================================================
+# 1. Tests for Basic Auth Enforcement Across Namespaces
+# =============================================================================
+
+def test_unauthenticated_request_rejected(client):
+ """Test requests without Basic Auth headers return 401 Unauthorized."""
+ endpoints = [
+ "/tfs/slice",
+ "/ixia/slice",
+ "/e2e/slice",
+ "/restconf/data/ietf-network-slice-service:network-slice-services"
+ ]
+ for endpoint in endpoints:
+ resp = client.get(endpoint)
+ assert resp.status_code == 401, f"Endpoint {endpoint} allowed unauthenticated access"
+
+
+# =============================================================================
+# 2. Tests for TFS Namespace (/tfs)
+# =============================================================================
+
+def test_tfs_slice_get(client, auth_headers, temp_sqlite_db):
+ """Test GET /tfs/slice retrieves slices."""
+ resp = client.get("/tfs/slice", headers=auth_headers)
+ assert resp.status_code in [200, 404]
+
+
+def test_tfs_slice_post_file(client, auth_headers, sample_ietf_intent, temp_sqlite_db):
+ """Test POST /tfs/slice with uploaded JSON file."""
+ data = {
+ 'file': (io.BytesIO(json.dumps(sample_ietf_intent).encode('utf-8')), 'intent.json')
+ }
+ with patch("src.main.send_controller", return_value=True), \
+ patch("src.realizer.send_controller.send_controller", return_value=True):
+ resp = client.post(
+ "/tfs/slice",
+ headers={"Authorization": auth_headers["Authorization"]},
+ data=data,
+ content_type='multipart/form-data'
+ )
+ assert resp.status_code in [200, 201]
+
+
+def test_tfs_slice_post_invalid_file_extension(client, auth_headers):
+ """Test POST /tfs/slice rejects non-JSON uploaded files."""
+ data = {
+ 'file': (io.BytesIO(b"hello world"), 'intent.txt')
+ }
+ resp = client.post(
+ "/tfs/slice",
+ headers={"Authorization": auth_headers["Authorization"]},
+ data=data,
+ content_type='multipart/form-data'
+ )
+ assert resp.status_code == 400
+ res_data = resp.get_json()
+ assert res_data["error"] == "Only JSON files allowed"
+
+
+def test_tfs_slice_post_invalid_json_string(client, auth_headers):
+ """Test POST /tfs/slice rejects malformed JSON string in form data."""
+ data = {
+ 'json_data': '{invalid_json: true'
+ }
+ resp = client.post(
+ "/tfs/slice",
+ headers={"Authorization": auth_headers["Authorization"]},
+ data=data,
+ content_type='multipart/form-data'
+ )
+ assert resp.status_code == 400
+ res_data = resp.get_json()
+ assert res_data["error"] == "JSON file not valid"
+
+
+# =============================================================================
+# 3. Tests for IXIA Namespace (/ixia)
+# =============================================================================
+
+def test_ixia_slice_get(client, auth_headers, temp_sqlite_db):
+ """Test GET /ixia/slice retrieves slices."""
+ resp = client.get("/ixia/slice", headers=auth_headers)
+ assert resp.status_code in [200, 404]
+
+
+def test_ixia_slice_post_form_data(client, auth_headers, sample_ietf_intent, temp_sqlite_db):
+ """Test POST /ixia/slice with JSON string in form data."""
+ data = {
+ 'json_data': json.dumps(sample_ietf_intent)
+ }
+ with patch("src.main.send_controller", return_value=True), \
+ patch("src.realizer.send_controller.send_controller", return_value=True):
+ resp = client.post(
+ "/ixia/slice",
+ headers={"Authorization": auth_headers["Authorization"]},
+ data=data,
+ content_type='multipart/form-data'
+ )
+ assert resp.status_code in [200, 201]
+
+
+# =============================================================================
+# 4. Tests for E2E Namespace (/e2e)
+# =============================================================================
+
+def test_e2e_slice_get(client, auth_headers, temp_sqlite_db):
+ """Test GET /e2e/slice retrieves slices."""
+ resp = client.get("/e2e/slice", headers=auth_headers)
+ assert resp.status_code in [200, 404]
+
+
+def test_e2e_alerts_post(client, auth_headers, temp_sqlite_db):
+ """Test POST /e2e/alert receives alert notifications."""
+ alert_payload = {
+ "tapi-notification:notification-context": [
+ {
+ "tapi-notification:notification": {
+ "uuid": "alert-uuid-123",
+ "notification-type": "ALARM_EVENT",
+ "event-time-stamp": "2026-08-04T12:00:00Z"
+ }
+ }
+ ]
+ }
+ resp = client.post(
+ "/e2e/alert",
+ headers=auth_headers,
+ data=json.dumps(alert_payload)
+ )
+ assert resp.status_code in [200, 201, 400]
+
+
+def test_e2e_alerts_post_missing_uuid(client, auth_headers):
+ """Test POST /e2e/alert handles alert payloads."""
+ resp = client.get(
+ "/e2e/alert",
+ headers=auth_headers
+ )
+ assert resp.status_code in [200, 404]
+
+
+# =============================================================================
+# 5. Tests for RESTCONF Namespace (/restconf)
+# =============================================================================
+
+def test_restconf_services_crud(client, auth_headers, sample_ietf_intent, temp_sqlite_db):
+ """Test GET, POST, PUT operations on RESTCONF network slice services endpoint."""
+ # GET (empty DB)
+ get_resp = client.get(
+ "/restconf/data/ietf-network-slice-service:network-slice-services",
+ headers=auth_headers
+ )
+ assert get_resp.status_code in [200, 404]
+
+ # POST (create)
+ post_resp = client.post(
+ "/restconf/data/ietf-network-slice-service:network-slice-services",
+ headers=auth_headers,
+ data=json.dumps(sample_ietf_intent)
+ )
+ assert post_resp.status_code in [200, 201, 500]
+
+ # PUT (update)
+ put_resp = client.put(
+ "/restconf/data/ietf-network-slice-service:network-slice-services",
+ headers=auth_headers,
+ data=json.dumps(sample_ietf_intent)
+ )
+ assert put_resp.status_code in [200, 404, 500]
+
+
+def test_delete_slice_endpoints(client, auth_headers):
+ """Test DELETE endpoints on /tfs, /ixia, and /e2e controllers."""
+ with patch("src.main.send_controller", return_value=True), \
+ patch("src.realizer.send_controller.send_controller", return_value=True):
+ res_tfs = client.delete("/tfs/slice/slice-1", headers=auth_headers)
+ assert res_tfs.status_code in [200, 204, 404, 500]
+
+ res_ixia = client.delete("/ixia/slice/slice-1", headers=auth_headers)
+ assert res_ixia.status_code in [200, 204, 404, 500]
+
+ res_e2e = client.delete("/e2e/slice/slice-1", headers=auth_headers)
+ assert res_e2e.status_code in [200, 204, 404, 500]
+
+
+def test_restconf_detailed_resources(client, auth_headers):
+ """Test RESTCONF specific resource endpoints."""
+ # Delete all slice services
+ del_all = client.delete(
+ "/restconf/data/ietf-network-slice-service:network-slice-services",
+ headers=auth_headers
+ )
+ assert del_all.status_code in [200, 204, 404, 500]
+
+ # Specific slice service GET & DELETE
+ get_spec = client.get(
+ "/restconf/data/ietf-network-slice-service:network-slice-services/slice-service=slice-123",
+ headers=auth_headers
+ )
+ assert get_spec.status_code in [200, 404]
+
+ del_spec = client.delete(
+ "/restconf/data/ietf-network-slice-service:network-slice-services/slice-service=slice-123",
+ headers=auth_headers
+ )
+ assert del_spec.status_code in [200, 204, 404, 500]
+
+
+# =============================================================================
+# 6. Extended Tests for IXIA and RESTCONF Namespaces
+# =============================================================================
+
+def test_ixia_namespace_extended(client, auth_headers):
+ """Test IXIA delete all, get slice by id, and put (modify) slice by id."""
+ # DELETE all slices
+ with patch("src.api.main.Api.delete_flows", return_value=({}, 204)):
+ resp_del_all = client.delete("/ixia/slice", headers=auth_headers)
+ assert resp_del_all.status_code in [200, 204, 500]
+
+ # GET slice by id
+ with patch("src.api.main.Api.get_flows", return_value=({"id": "slice-1"}, 200)):
+ resp_get = client.get("/ixia/slice/slice-1", headers=auth_headers)
+ assert resp_get.status_code in [200, 404, 500]
+
+ # PUT (modify) slice by id
+ with patch("src.api.main.Api.modify_flow", return_value=({"id": "slice-1"}, 200)):
+ resp_put = client.put(
+ "/ixia/slice/slice-1",
+ headers=auth_headers,
+ data=json.dumps({"intent": "modified"})
+ )
+ assert resp_put.status_code in [200, 400, 404, 500]
+
+
+def test_restconf_slice_services_extended(client, auth_headers):
+ """Test GET, POST, DELETE slice-service list and GET, PUT, DELETE specific slice-service."""
+ # GET /slice-service
+ with patch("src.api.main.Api.get_slice_services", return_value=([], 200)):
+ res_get_list = client.get(
+ "/restconf/data/ietf-network-slice-service:network-slice-services/slice-service",
+ headers=auth_headers
+ )
+ assert res_get_list.status_code in [200, 404]
+
+ # POST /slice-service
+ with patch("src.api.main.Api.add_slice_service", return_value=({}, 201)):
+ res_post_list = client.post(
+ "/restconf/data/ietf-network-slice-service:network-slice-services/slice-service",
+ headers=auth_headers,
+ data=json.dumps({"id": "slice-1"})
+ )
+ assert res_post_list.status_code in [200, 201, 409, 500]
+
+ # DELETE /slice-service
+ with patch("src.api.main.Api.delete_slice_services", return_value=({}, 204)):
+ res_del_list = client.delete(
+ "/restconf/data/ietf-network-slice-service:network-slice-services/slice-service",
+ headers=auth_headers
+ )
+ assert res_del_list.status_code in [200, 204, 500]
+
+ # PUT /slice-service=
+ with patch("src.api.main.Api.update_slice_service", return_value=({}, 200)):
+ res_put_spec = client.put(
+ "/restconf/data/ietf-network-slice-service:network-slice-services/slice-service=slice-1",
+ headers=auth_headers,
+ data=json.dumps({"id": "slice-1"})
+ )
+ assert res_put_spec.status_code in [200, 201, 404, 500]
+
+
+def test_restconf_slo_sle_templates_extended(client, auth_headers):
+ """Test GET, POST, DELETE for slo-sle-templates list and GET, PUT, DELETE for specific template by ID."""
+ base_url = "/restconf/data/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template"
+
+ # GET /slo-sle-template
+ with patch("src.api.main.Api.get_slo_sle_templates", return_value=([], 200)):
+ res_get = client.get(base_url, headers=auth_headers)
+ assert res_get.status_code in [200, 404]
+
+ # POST /slo-sle-template
+ with patch("src.api.main.Api.add_slo_sle_template", return_value=({}, 201)):
+ res_post = client.post(base_url, headers=auth_headers, data=json.dumps({"id": "tmpl-1"}))
+ assert res_post.status_code in [200, 201, 409]
+
+ # DELETE /slo-sle-template
+ with patch("src.api.main.Api.delete_slo_sle_templates", return_value=({}, 204)):
+ res_del = client.delete(base_url, headers=auth_headers)
+ assert res_del.status_code in [200, 204]
+
+ # GET /slo-sle-template=
+ with patch("src.api.main.Api.get_slo_sle_templates", return_value=({"id": "tmpl-1"}, 200)):
+ res_get_spec = client.get(
+ "/restconf/data/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template=tmpl-1",
+ headers=auth_headers
+ )
+ assert res_get_spec.status_code in [200, 404]
+
+ # PUT /slo-sle-template=
+ with patch("src.api.main.Api.update_slo_sle_template", return_value=({}, 200)):
+ res_put_spec = client.put(
+ "/restconf/data/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template=tmpl-1",
+ headers=auth_headers,
+ data=json.dumps({"id": "tmpl-1"})
+ )
+ assert res_put_spec.status_code in [200, 404]
+
+ # DELETE /slo-sle-template=
+ with patch("src.api.main.Api.delete_slo_sle_templates", return_value=({}, 204)):
+ res_del_spec = client.delete(
+ "/restconf/data/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template=tmpl-1",
+ headers=auth_headers
+ )
+ assert res_del_spec.status_code in [200, 204]
+
+
+def test_restconf_sdps_extended(client, auth_headers):
+ """Test GET, POST, DELETE for SDPs list and GET, PUT, DELETE for specific SDP by ID."""
+ base_url = "/restconf/data/ietf-network-slice-service:network-slice-services/slice-service=slice-1/sdps"
+
+ # GET /sdps
+ with patch("src.api.main.Api.get_sdps", return_value=([], 200)):
+ res_get = client.get(base_url, headers=auth_headers)
+ assert res_get.status_code in [200, 404]
+
+ # POST /sdps
+ with patch("src.api.main.Api.add_sdp", return_value=({}, 201)):
+ res_post = client.post(base_url, headers=auth_headers, data=json.dumps({"id": "sdp-1"}))
+ assert res_post.status_code in [200, 201, 409]
+
+ # DELETE /sdps
+ with patch("src.api.main.Api.delete_sdps", return_value=({}, 204)):
+ res_del = client.delete(base_url, headers=auth_headers)
+ assert res_del.status_code in [200, 204]
+
+ spec_url = f"{base_url}/sdp=sdp-1"
+
+ # GET /sdps/sdp=
+ with patch("src.api.main.Api.get_sdps", return_value=({"id": "sdp-1"}, 200)):
+ res_get_spec = client.get(spec_url, headers=auth_headers)
+ assert res_get_spec.status_code in [200, 404]
+
+ # PUT /sdps/sdp=
+ with patch("src.api.main.Api.update_sdp", return_value=({}, 200)):
+ res_put_spec = client.put(spec_url, headers=auth_headers, data=json.dumps({"id": "sdp-1"}))
+ assert res_put_spec.status_code in [200, 404]
+
+ # DELETE /sdps/sdp=
+ with patch("src.api.main.Api.delete_sdps", return_value=({}, 204)):
+ res_del_spec = client.delete(spec_url, headers=auth_headers)
+ assert res_del_spec.status_code in [200, 204]
+
+
+def test_restconf_telemetry_clients_extended(client, auth_headers):
+ """Test telemetry client endpoints GET, DELETE all and GET, POST, DELETE specific client."""
+ # GET list
+ with patch("src.api.main.Api.get_clients", return_value=([], 200)):
+ res_get_list = client.get("/restconf/operations/telemetry/client", headers=auth_headers)
+ assert res_get_list.status_code in [200, 404, 500]
+
+ # DELETE list
+ with patch("src.api.main.Api.delete_clients", return_value=({}, 204)):
+ res_del_list = client.delete("/restconf/operations/telemetry/client", headers=auth_headers)
+ assert res_del_list.status_code in [200, 204, 500]
+
+ # GET specific client
+ with patch("src.api.main.Api.get_clients", return_value=({"client_id": "cli-1"}, 200)):
+ res_get = client.get("/restconf/operations/telemetry/client/cli-1", headers=auth_headers)
+ assert res_get.status_code in [200, 404, 500]
+
+ # POST create client
+ with patch("src.api.main.Api.add_client", return_value=({"client_id": "cli-1"}, 201)):
+ res_post = client.post("/restconf/operations/telemetry/client/cli-1", headers=auth_headers)
+ assert res_post.status_code in [200, 201, 500]
+
+ # DELETE specific client
+ with patch("src.api.main.Api.delete_clients", return_value=({}, 204)):
+ res_del = client.delete("/restconf/operations/telemetry/client/cli-1", headers=auth_headers)
+ assert res_del.status_code in [200, 204, 404, 500]
+
+
+def test_restconf_telemetry_subscriptions_extended(client, auth_headers):
+ """Test telemetry subscriptions endpoints for client and slice level."""
+ # GET client subscriptions
+ with patch("src.api.main.Api.get_subscriptions", return_value=([], 200)):
+ res_get = client.get("/restconf/operations/telemetry/subscription/cli-1", headers=auth_headers)
+ assert res_get.status_code in [200, 500]
+
+ # POST create subscription
+ with patch("src.api.main.Api.add_subscription", return_value=({}, 201)):
+ res_post = client.post(
+ "/restconf/operations/telemetry/subscription/cli-1",
+ headers=auth_headers,
+ data=json.dumps({"slice_id": "slice-1", "frequency": 5})
+ )
+ assert res_post.status_code in [200, 201, 400, 500]
+
+ # DELETE all subscriptions
+ with patch("src.api.main.Api.delete_subscriptions", return_value=({}, 204)):
+ res_del = client.delete("/restconf/operations/telemetry/subscription/cli-1", headers=auth_headers)
+ assert res_del.status_code in [200, 204, 500]
+
+ # GET specific subscription
+ with patch("src.api.main.Api.get_subscriptions", return_value=({}, 200)):
+ res_get_spec = client.get("/restconf/operations/telemetry/subscription/cli-1/slice/slice-1", headers=auth_headers)
+ assert res_get_spec.status_code in [200, 500]
+
+ # PUT update subscription
+ with patch("src.api.main.Api.update_subscription", return_value=({}, 201)):
+ res_put_spec = client.put(
+ "/restconf/operations/telemetry/subscription/cli-1/slice/slice-1",
+ headers=auth_headers,
+ data=json.dumps({"frequency": 10})
+ )
+ assert res_put_spec.status_code in [200, 201, 400, 500]
+
+ # DELETE specific subscription
+ with patch("src.api.main.Api.delete_subscriptions", return_value=({}, 204)):
+ res_del_spec = client.delete("/restconf/operations/telemetry/subscription/cli-1/slice/slice-1", headers=auth_headers)
+ assert res_del_spec.status_code in [200, 204, 500]
+
+
+def test_restconf_telemetry_stream_and_slice(client, auth_headers):
+ """Test telemetry streaming endpoint and telemetry slice list / slice ID endpoints."""
+ # Stream endpoint
+ with patch("src.api.main.Api.sync_stream", return_value=iter(["data: {}\n\n"])):
+ res_stream = client.get("/restconf/operations/telemetry/subscription/cli-1/slice/slice-1/stream", headers=auth_headers)
+ assert res_stream.status_code in [200, 500]
+
+ # GET telemetry for all slices
+ with patch("src.api.main.Api.get_telemetry", return_value=([], 200)):
+ res_telem_all = client.get("/restconf/operations/telemetry/slice", headers=auth_headers)
+ assert res_telem_all.status_code in [200, 500]
+
+ # GET telemetry for specific slice
+ with patch("src.api.main.Api.get_telemetry", return_value=({}, 200)):
+ res_telem_spec = client.get("/restconf/operations/telemetry/slice/slice-1", headers=auth_headers)
+ assert res_telem_spec.status_code in [200, 404, 500]
+
diff --git a/src/tests/test_nbi_processor.py b/src/tests/test_nbi_processor.py
index fecb1092cea286f4d08d1cca715f594922dbdabd..29f715f6f18df8faf8d572ee6e864793cf997c32 100644
--- a/src/tests/test_nbi_processor.py
+++ b/src/tests/test_nbi_processor.py
@@ -237,3 +237,12 @@ def test_translator_with_single_endpoint_should_fail(mock_load_template, gpp_int
gpp_intent["subnetA"]["EpTransport"] = ["EpTransport ep1"] # solo uno
with pytest.raises(IndexError):
translator(gpp_intent, "subnetA")
+
+
+def test_detect_format_edge_cases():
+ assert detect_format({}) is None
+
+
+def test_nbi_processor_unknown_format():
+ with pytest.raises(ValueError, match="JSON request format not recognized"):
+ nbi_processor({"unknown_format": True})
diff --git a/src/tests/test_planner.py b/src/tests/test_planner.py
new file mode 100644
index 0000000000000000000000000000000000000000..32837f50737d8268b0e2255395cd76b1e6f13110
--- /dev/null
+++ b/src/tests/test_planner.py
@@ -0,0 +1,307 @@
+# Copyright 2022-2026 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/)
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# This file is an original contribution from Telefonica Innovación Digital S.L.
+
+import pytest
+from unittest.mock import patch, MagicMock
+import requests
+
+from src.planner.planner import Planner
+from src.planner.shortest_path import normalize_node_id, get_shortest_path
+from src.planner.energy_planner.energy import energy_planner, retrieve_energy, retrieve_topology
+from src.planner.hrat_planner.hrat import hrat_planner
+from src.planner.e2e_optical_planner.e2e_optical import e2e_optical_planner
+
+
+# =============================================================================
+# 1. Tests for main Planner Dispatcher (src/planner/planner.py)
+# =============================================================================
+
+def test_planner_dispatch_energy(flask_app, sample_ietf_intent):
+ """Test Planner.planner dispatches to energy_planner when type is ENERGY."""
+ planner = Planner()
+ with patch("src.planner.planner.energy_planner") as mock_energy:
+ mock_energy.return_value = ["A", "B"]
+ with flask_app.app_context():
+ res = planner.planner(sample_ietf_intent, type="ENERGY")
+ assert res == ["A", "B"]
+ mock_energy.assert_called_once_with(sample_ietf_intent)
+
+
+def test_planner_dispatch_hrat(flask_app, sample_ietf_intent):
+ """Test Planner.planner dispatches to hrat_planner when type is HRAT."""
+ planner = Planner()
+ with patch("src.planner.planner.hrat_planner") as mock_hrat:
+ mock_hrat.return_value = {"viability": True}
+ with flask_app.app_context():
+ res = planner.planner(sample_ietf_intent, type="HRAT")
+ assert res == {"viability": True}
+ mock_hrat.assert_called_once_with(sample_ietf_intent, "10.0.0.1")
+
+
+def test_planner_dispatch_e2e_optical(flask_app, sample_ietf_intent):
+ """Test Planner.planner dispatches to e2e_optical_planner when type is E2E_OPTICAL."""
+ planner = Planner()
+ with patch("src.planner.planner.e2e_optical_planner") as mock_opt:
+ mock_opt.return_value = {"path": ["A", "B"]}
+ with flask_app.app_context():
+ res = planner.planner(sample_ietf_intent, type="E2E_OPTICAL", is_update=False)
+ assert res == {"path": ["A", "B"]}
+
+ res_update = planner.planner(sample_ietf_intent, type="E2E_OPTICAL", is_update=True)
+ assert res_update == {"path": ["A", "B"]}
+ assert mock_opt.call_count == 2
+
+
+def test_planner_dispatch_invalid(flask_app, sample_ietf_intent):
+ """Test Planner.planner returns None for unknown strategy types."""
+ planner = Planner()
+ with flask_app.app_context():
+ res = planner.planner(sample_ietf_intent, type="INVALID_STRATEGY")
+ assert res is None
+
+
+# =============================================================================
+# 2. Tests for Shortest Path Algorithm (src/planner/shortest_path.py)
+# =============================================================================
+
+def test_normalize_node_id():
+ """Test normalization of node URN identifiers."""
+ assert normalize_node_id("urn:tfs:node:A") == "A"
+ assert normalize_node_id("B") == "B"
+ assert normalize_node_id(123) == 123
+
+
+def test_get_shortest_path_success():
+ """Test successful shortest path computation on undirected graph."""
+ network = {
+ "node": [
+ {"node-id": "urn:tfs:node:A"},
+ {"node-id": "urn:tfs:node:B"},
+ {"node-id": "urn:tfs:node:C"},
+ ],
+ "ietf-network-topology:link": [
+ {
+ "source": {"source-node": "urn:tfs:node:A"},
+ "destination": {"dest-node": "urn:tfs:node:B"}
+ },
+ {
+ "source": {"source-node": "urn:tfs:node:B"},
+ "destination": {"dest-node": "urn:tfs:node:C"}
+ }
+ ]
+ }
+ path, code = get_shortest_path(network, "urn:tfs:node:A", "urn:tfs:node:C", directed_graph=False)
+ assert code == 200
+ assert path == ["A", "B", "C"]
+
+
+def test_get_shortest_path_missing_source():
+ """Test error handling when source node is absent."""
+ network = {
+ "node": [{"node-id": "B"}],
+ "ietf-network-topology:link": []
+ }
+ res, code = get_shortest_path(network, "A", "B")
+ assert code == 404
+ assert res == {"message": "Source node 'A' not found"}
+
+
+def test_get_shortest_path_missing_destination():
+ """Test error handling when destination node is absent."""
+ network = {
+ "node": [{"node-id": "A"}],
+ "ietf-network-topology:link": []
+ }
+ res, code = get_shortest_path(network, "A", "B")
+ assert code == 404
+ assert res == {"message": "Destination node 'B' not found"}
+
+
+def test_get_shortest_path_no_path():
+ """Test error handling when destination is disconnected from source."""
+ network = {
+ "node": [{"node-id": "A"}, {"node-id": "B"}],
+ "ietf-network-topology:link": []
+ }
+ res, code = get_shortest_path(network, "A", "B")
+ assert code == 404
+ assert res == {"message": "No path found"}
+
+
+def test_get_shortest_path_directed():
+ """Test shortest path on a directed graph."""
+ network = {
+ "node": [{"node-id": "A"}, {"node-id": "B"}],
+ "ietf-network-topology:link": [
+ {
+ "source": {"source-node": "A"},
+ "destination": {"dest-node": "B"}
+ }
+ ]
+ }
+ # Path A -> B should succeed
+ path, code = get_shortest_path(network, "A", "B", directed_graph=True)
+ assert code == 200
+ assert path == ["A", "B"]
+
+ # Path B -> A should fail on directed graph
+ res, code = get_shortest_path(network, "B", "A", directed_graph=True)
+ assert code == 404
+ assert res == {"message": "No path found"}
+
+
+# =============================================================================
+# 3. Tests for Energy Planner (src/planner/energy_planner/energy.py)
+# =============================================================================
+
+def test_retrieve_energy_and_topology(flask_app):
+ """Test metric and topology dataset loading functions."""
+ energy = retrieve_energy()
+ with flask_app.app_context():
+ topology = retrieve_topology()
+ assert isinstance(topology, dict)
+ assert isinstance(energy, list)
+
+
+def test_energy_planner_invalid_nodes(flask_app):
+ """Test energy planner returns None when source/dest nodes are outside allowed set."""
+ intent = {
+ "ietf-network-slice-service:network-slice-services": {
+ "slice-service": [{
+ "sdps": {
+ "sdp": [
+ {"node-id": "NODE_X"},
+ {"node-id": "NODE_Y"}
+ ]
+ }
+ }]
+ }
+ }
+ with flask_app.app_context():
+ res = energy_planner(intent)
+ assert res is None
+
+
+def test_energy_planner_internal(flask_app, sample_ietf_intent):
+ """Test internal Dijkstra-based energy planner execution."""
+ flask_app.config["PCE_EXTERNAL"] = False
+ with flask_app.app_context():
+ path = energy_planner(sample_ietf_intent)
+ assert path is not None
+ assert isinstance(path, list)
+ assert path[0] == "A"
+ assert path[-1] == "B"
+
+
+def test_energy_planner_pce_external(flask_app, sample_ietf_intent):
+ """Test external PCE energy planner path computation."""
+ flask_app.config["PCE_EXTERNAL"] = True
+ with flask_app.app_context():
+ path = energy_planner(sample_ietf_intent)
+ assert path is not None
+ assert isinstance(path, list)
+
+
+# =============================================================================
+# 4. Tests for HRAT Planner (src/planner/hrat_planner/hrat.py)
+# =============================================================================
+
+@patch("requests.post")
+def test_hrat_planner_create_success(mock_post):
+ """Test HRAT create action success path."""
+ mock_resp = MagicMock()
+ mock_resp.ok = True
+ mock_resp.json.return_value = {"network-slice-uuid": "test-uuid", "viability": True}
+ mock_post.return_value = mock_resp
+
+ res = hrat_planner(data={"test": "payload"}, ip="10.0.0.1", action="create")
+ assert res == {"network-slice-uuid": "test-uuid", "viability": True}
+ mock_post.assert_called_once()
+
+
+@patch("requests.delete")
+def test_hrat_planner_delete_success(mock_delete):
+ """Test HRAT delete action success path."""
+ mock_resp = MagicMock()
+ mock_resp.ok = True
+ mock_resp.json.return_value = {"network-slice-uuid": "slice-1", "status": "deleted"}
+ mock_delete.return_value = mock_resp
+
+ res = hrat_planner(data="slice-1", ip="10.0.0.1", action="delete")
+ assert res == {"network-slice-uuid": "slice-1", "status": "deleted"}
+ mock_delete.assert_called_once()
+
+
+def test_hrat_planner_invalid_action():
+ """Test HRAT planner fallback on invalid action."""
+ res = hrat_planner(data={}, ip="10.0.0.1", action="invalid_action")
+ assert "network-slice-uuid" in res
+ assert res["viability"] is True
+
+
+@patch("requests.post")
+def test_hrat_planner_http_error(mock_post):
+ """Test HRAT planner handles HTTP failure by returning fallback data."""
+ mock_post.side_effect = requests.exceptions.RequestException("Connection refused")
+ res = hrat_planner(data={}, ip="10.0.0.1", action="create")
+ assert "network-slice-uuid" in res
+ assert res["viability"] is True
+
+
+# =============================================================================
+# 5. Tests for E2E Optical Planner (src/planner/e2e_optical_planner/e2e_optical.py)
+# =============================================================================
+
+@patch("requests.post")
+def test_e2e_optical_planner_create_success(mock_post):
+ """Test E2E Optical planner path creation success."""
+ mock_resp = MagicMock()
+ mock_resp.status_code = 200
+ mock_resp.json.return_value = {"path_id": "opt-1", "nodes": ["A", "B"]}
+ mock_post.return_value = mock_resp
+
+ res = e2e_optical_planner(intent={"test": 1}, ip="10.0.0.1", action="create")
+ assert res == {"path_id": "opt-1", "nodes": ["A", "B"]}
+ assert "e2e_path_computation" in mock_post.call_args[0][0]
+
+
+@patch("requests.post")
+def test_e2e_optical_planner_update(mock_post):
+ """Test E2E Optical planner path recomputation update action."""
+ mock_resp = MagicMock()
+ mock_resp.status_code = 201
+ mock_resp.json.return_value = {"path_id": "opt-1", "updated": True}
+ mock_post.return_value = mock_resp
+
+ res = e2e_optical_planner(intent={"test": 1}, ip="10.0.0.1", action="update")
+ assert res == {"path_id": "opt-1", "updated": True}
+ assert "recompute_optical_path" in mock_post.call_args[0][0]
+
+
+@patch("requests.post")
+def test_e2e_optical_planner_failure(mock_post):
+ """Test E2E Optical planner returns None on request failure or exception."""
+ mock_resp = MagicMock()
+ mock_resp.status_code = 500
+ mock_resp.text = "Internal Server Error"
+ mock_post.return_value = mock_resp
+
+ res = e2e_optical_planner(intent={}, ip="10.0.0.1", action="create")
+ assert res is None
+
+ mock_post.side_effect = requests.exceptions.Timeout("Timed out")
+ res_timeout = e2e_optical_planner(intent={}, ip="10.0.0.1", action="create")
+ assert res_timeout is None
diff --git a/src/tests/test_realizer.py b/src/tests/test_realizer.py
new file mode 100644
index 0000000000000000000000000000000000000000..134899bf45ff22448c61c33c23914d02266831ad
--- /dev/null
+++ b/src/tests/test_realizer.py
@@ -0,0 +1,682 @@
+# Copyright 2022-2026 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/)
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# This file is an original contribution from Telefonica Innovación Digital S.L.
+
+import pytest
+from unittest.mock import patch, MagicMock
+from src.realizer.main import realizer
+from src.realizer.select_way import select_way
+from src.realizer.send_controller import send_controller
+from src.realizer.nrp_handler import nrp_handler
+from src.realizer.get_metrics import get_metrics
+
+
+# =============================================================================
+# 1. Tests for Realizer Entrypoint (src/realizer/main.py)
+# =============================================================================
+
+def test_realizer_create_with_nrp():
+ """Test realizer CREATE flow when need_nrp is True."""
+ with patch("src.realizer.main.nrp_handler") as mock_nrp:
+ mock_nrp.return_value = [{"id": "nrp-1"}]
+ res = realizer(payload={"service": 1}, need_nrp=True, order="READ", nrp={"id": "nrp-1"}, action="CREATE")
+ assert res == [{"id": "nrp-1"}]
+ mock_nrp.assert_called_once_with("READ", {"id": "nrp-1"})
+
+
+@pytest.mark.parametrize("rule_type,expected_way", [
+ ("XR_AGENT_ACTIVATE_TRANSCEIVER", "L3oWDM"),
+ ("PROVISION_MEDIA_CHANNEL_AND_CONFIG_VPNL3", "L3oWDM"),
+ ("PROVISION_MEDIA_CHANNEL_AND_CONFIG_VPNL2", "L2oWDM"),
+ ("PROVISION_MEDIA_CHANNEL", "OPTIC"),
+ ("CONFIG_VPNL3", "L3VPN"),
+ ("CONFIG_VPNL2", "L2VPN"),
+ ("DEACTIVATE_XR_AGENT_TRANSCEIVER", "DEL_L3oWDM"),
+ ("DEPROVISION_OPTICAL_RESOURCE_AND_REMOVE_VPNL3", "DEL_L3oWDM"),
+ ("DEPROVISION_OPTICAL_RESOURCE_AND_REMOVE_VPNL2", "DEL_L2oWDM"),
+ ("DEPROVISION_OPTICAL_RESOURCE", "DEL_OPTIC"),
+ ("REMOVE_VPNL3", "DEL_L3VPN"),
+ ("REMOVE_VPNL2", "DEL_L2VPN"),
+])
+def test_realizer_create_e2e_rules(rule_type, expected_way):
+ """Test E2E rule resolution maps action types to correct realization ways."""
+ rules = []
+ if rule_type == "XR_AGENT_ACTIVATE_TRANSCEIVER":
+ rules = [{"actions": [{"type": "XR_AGENT_ACTIVATE_TRANSCEIVER"}]}]
+ elif rule_type == "PROVISION_MEDIA_CHANNEL_AND_CONFIG_VPNL3":
+ rules = [{"actions": [{"type": "PROVISION_MEDIA_CHANNEL"}, {"type": "CONFIG_VPNL3"}]}]
+ elif rule_type == "PROVISION_MEDIA_CHANNEL_AND_CONFIG_VPNL2":
+ rules = [{"actions": [{"type": "PROVISION_MEDIA_CHANNEL"}, {"type": "CONFIG_VPNL2"}]}]
+ elif rule_type == "PROVISION_MEDIA_CHANNEL":
+ rules = [{"actions": [{"type": "PROVISION_MEDIA_CHANNEL"}]}]
+ elif rule_type == "CONFIG_VPNL3":
+ rules = [{"actions": [{"type": "CONFIG_VPNL3"}]}]
+ elif rule_type == "CONFIG_VPNL2":
+ rules = [{"actions": [{"type": "CONFIG_VPNL2"}]}]
+ elif rule_type == "DEACTIVATE_XR_AGENT_TRANSCEIVER":
+ rules = [{"actions": [{"type": "DEACTIVATE_XR_AGENT_TRANSCEIVER"}]}]
+ elif rule_type == "DEPROVISION_OPTICAL_RESOURCE_AND_REMOVE_VPNL3":
+ rules = [{"actions": [{"type": "DEPROVISION_OPTICAL_RESOURCE"}, {"type": "REMOVE_VPNL3"}]}]
+ elif rule_type == "DEPROVISION_OPTICAL_RESOURCE_AND_REMOVE_VPNL2":
+ rules = [{"actions": [{"type": "DEPROVISION_OPTICAL_RESOURCE"}, {"type": "REMOVE_VPNL2"}]}]
+ elif rule_type == "DEPROVISION_OPTICAL_RESOURCE":
+ rules = [{"actions": [{"type": "DEPROVISION_OPTICAL_RESOURCE"}]}]
+ elif rule_type == "REMOVE_VPNL3":
+ rules = [{"actions": [{"type": "REMOVE_VPNL3"}]}]
+ elif rule_type == "REMOVE_VPNL2":
+ rules = [{"actions": [{"type": "REMOVE_VPNL2"}]}]
+
+ with patch("src.realizer.main.select_way") as mock_select:
+ mock_select.return_value = {"way": expected_way}
+ res = realizer(payload={}, controller_type="E2E", rules=rules, action="CREATE")
+ assert res == {"way": expected_way}
+ assert mock_select.call_args[1]["way"] == expected_way
+
+
+def test_realizer_create_e2e_invalid_rules():
+ """Test E2E rule resolution returns None when rules cannot be matched."""
+ rules = [{"actions": [{"type": "UNKNOWN_ACTION"}]}]
+ res = realizer(payload={}, controller_type="E2E", rules=rules, action="CREATE")
+ assert res is None
+
+
+def test_realizer_create_service_tag_way(sample_ietf_intent):
+ """Test service tag fallback way extraction when controller is not E2E."""
+ with patch("src.realizer.main.select_way") as mock_select:
+ mock_select.return_value = {"ok": True}
+ res = realizer(payload=sample_ietf_intent, controller_type="TFS", action="CREATE")
+ assert res == {"ok": True}
+ assert mock_select.call_args[1]["way"] == "L3VPN"
+
+
+def test_realizer_monitor_success(flask_app):
+ """Test realizer MONITOR action succeeds when topology and shortest path are retrieved."""
+ payload = {
+ "slice_id": "slice-1",
+ "sdps": {
+ "network-slice-services": {
+ "slice-service": {
+ "slice-1": {
+ "sdps": {
+ "sdp": [{"id": "A"}, {"id": "B"}]
+ }
+ }
+ }
+ }
+ }
+ }
+ with patch("src.realizer.main.tfs_connector") as mock_tfs_cls, \
+ patch("src.realizer.main.get_shortest_path") as mock_sp, \
+ patch("src.realizer.main.get_metrics") as mock_gm:
+
+ mock_conn = MagicMock()
+ mock_conn.get_network_topology.return_value = ({"node": []}, 200)
+ mock_tfs_cls.return_value = mock_conn
+
+ mock_sp.return_value = (["A", "B"], 200)
+
+ with flask_app.app_context():
+ realizer(payload=payload, controller_type="RESTCONF", action="MONITOR")
+ mock_sp.assert_called_once_with({"node": []}, "A", "B")
+ mock_gm.assert_called_once_with(["A", "B"], "slice-1", "RESTCONF")
+
+
+def test_realizer_monitor_topology_failure(flask_app):
+ """Test realizer MONITOR action raises exception when topology retrieval fails."""
+ payload = {
+ "slice_id": "slice-1",
+ "sdps": {
+ "network-slice-services": {
+ "slice-service": {
+ "slice-1": {
+ "sdps": {
+ "sdp": [{"id": "A"}, {"id": "B"}]
+ }
+ }
+ }
+ }
+ }
+ }
+ with patch("src.realizer.main.tfs_connector") as mock_tfs_cls:
+ mock_conn = MagicMock()
+ mock_conn.get_network_topology.return_value = (None, 500)
+ mock_tfs_cls.return_value = mock_conn
+
+ with flask_app.app_context():
+ with pytest.raises(Exception, match="Error: Topology not retrieved"):
+ realizer(payload=payload, controller_type="RESTCONF", action="MONITOR")
+
+
+# =============================================================================
+# 2. Tests for Select Way Dispatcher (src/realizer/select_way.py)
+# =============================================================================
+
+@patch("src.realizer.select_way.tfs")
+@patch("src.realizer.select_way.ixia")
+@patch("src.realizer.select_way.e2e")
+@patch("src.realizer.select_way.restconf")
+def test_select_way_dispatchers(mock_restconf, mock_e2e, mock_ixia, mock_tfs):
+ """Test select_way routes request to appropriate controller handler."""
+ select_way(controller="TFS", way="L3VPN", ietf_intent={})
+ mock_tfs.assert_called_once()
+
+ select_way(controller="IXIA", way="L3VPN", ietf_intent={})
+ mock_ixia.assert_called_once()
+
+ select_way(controller="E2E", way="L3oWDM", ietf_intent={})
+ mock_e2e.assert_called_once()
+
+ select_way(controller="RESTCONF", way="L3VPN", ietf_intent={})
+ mock_restconf.assert_called_once()
+
+ # Unknown controller defaults to TFS
+ select_way(controller="UNKNOWN", way="L3VPN", ietf_intent={})
+ assert mock_tfs.call_count == 2
+
+
+# =============================================================================
+# 3. Tests for Send Controller Dispatcher (src/realizer/send_controller.py)
+# =============================================================================
+
+def test_send_controller_dummy_mode(flask_app):
+ """Test send_controller returns True when DUMMY_MODE is enabled."""
+ flask_app.config["DUMMY_MODE"] = True
+ with flask_app.app_context():
+ res = send_controller("TFS", {"req": 1})
+ assert res is True
+
+
+@patch("src.realizer.send_controller.tfs_connect")
+@patch("src.realizer.send_controller.ixia_connect")
+@patch("src.realizer.send_controller.e2e_connect")
+@patch("src.realizer.send_controller.restconf_connect")
+def test_send_controller_non_dummy(mock_rc, mock_e2e, mock_ixia, mock_tfs, flask_app):
+ """Test send_controller dispatches requests to appropriate connector in non-dummy mode."""
+ flask_app.config["DUMMY_MODE"] = False
+ flask_app.config["TFS_IP"] = "10.0.0.1"
+ flask_app.config["IXIA_IP"] = "10.0.0.2"
+ flask_app.config["TFS_E2E_IP"] = "10.0.0.3"
+ flask_app.config["RESTCONF_IP"] = "10.0.0.4"
+
+ mock_tfs.return_value = True
+ mock_ixia.return_value = True
+ mock_e2e.return_value = True
+ mock_rc.return_value = True
+
+ with flask_app.app_context():
+ send_controller("TFS", {"req": 1})
+ mock_tfs.assert_called_once_with({"req": 1}, "10.0.0.1")
+
+ send_controller("IXIA", {"req": 2})
+ mock_ixia.assert_called_once_with({"req": 2}, "10.0.0.2")
+
+ send_controller("E2E", {"req": 3})
+ mock_e2e.assert_called_once_with({"req": 3}, "10.0.0.3", is_update=False, old_service_id=None)
+
+ send_controller("RESTCONF", {"req": 4})
+ mock_rc.assert_called_once_with({"req": 4}, "10.0.0.4")
+
+
+# =============================================================================
+# 4. Tests for NRP Handler (src/realizer/nrp_handler.py)
+# =============================================================================
+
+def test_nrp_handler_operations(tmp_path, monkeypatch):
+ """Test NRP handler database READ, CREATE, UPDATE operations."""
+ nrp_file = tmp_path / "nrp_ddbb.json"
+ nrp_file.write_text("[]")
+ monkeypatch.setattr("src.realizer.nrp_handler.DATABASE_PATH", str(tmp_path))
+
+ # READ
+ view = nrp_handler("READ", None)
+ assert view == []
+
+ # CREATE
+ nrp_item = {"id": "nrp-100", "bw": 1000}
+ res = nrp_handler("CREATE", nrp_item)
+ assert res is None
+
+ # UPDATE
+ nrp_handler("UPDATE", nrp_item)
+
+
+# =============================================================================
+# 5. Tests for Get Metrics Streamer (src/realizer/get_metrics.py)
+# =============================================================================
+
+def test_get_metrics(flask_app):
+ """Test telemetry metrics stream initialization and cache skipping."""
+ flask_app.config["TELEMETRY_CACHE"] = {}
+ flask_app.config["RESTCONF_IP"] = "10.0.0.1"
+ flask_app.config["SDN_SUBSCRIPTION_PERIOD"] = 10
+
+ path = ["A", "B", "C"]
+ slice_id = "slice-test-1"
+
+ with patch("src.realizer.get_metrics.tfs_connector") as mock_connector_cls:
+ mock_connector = MagicMock()
+ mock_bg_loop = MagicMock()
+ mock_connector.get_background_loop.return_value = mock_bg_loop
+ mock_connector_cls.return_value = mock_connector
+
+ with patch("asyncio.run_coroutine_threadsafe") as mock_rct:
+ mock_fut = MagicMock()
+ mock_rct.return_value = mock_fut
+
+ with flask_app.app_context():
+ # First run - cache miss
+ get_metrics(path, slice_id, controller_type="RESTCONF")
+ assert slice_id in flask_app.config["TELEMETRY_CACHE"]
+ assert "A-B" in flask_app.config["TELEMETRY_CACHE"][slice_id]
+ assert "B-C" in flask_app.config["TELEMETRY_CACHE"][slice_id]
+ mock_connector.startStreams.assert_called_once()
+
+ # Second run - cache hit
+ get_metrics(path, slice_id, controller_type="RESTCONF")
+ # startStreams should not be called again
+ assert mock_connector.startStreams.call_count == 1
+
+
+# =============================================================================
+# 6. Tests for RESTCONF Service Types & Builders
+# =============================================================================
+
+class TestRestconfServiceTypesAndBuilders:
+ """Full coverage unit tests for RESTCONF service types (l2vpn, l3vpn) and builders."""
+
+ def test_l2vpn_and_l3vpn_empty_sdps(self):
+ from src.realizer.restconf.service_types.l2vpn import l2vpn
+ from src.realizer.restconf.service_types.l3vpn import l3vpn
+
+ intent_no_sdps = {"id": "slice-1", "connectivity_type": "point-to-point"}
+ assert l2vpn(intent_no_sdps) is None
+ assert l3vpn(intent_no_sdps) is None
+
+ def test_l2vpn_realization_valid_sdps(self):
+ from src.realizer.restconf.service_types.l2vpn import l2vpn
+
+ intent = {
+ "id": "l2-slice-1",
+ "connectivity_type": "point-to-point",
+ "sdps": [
+ {
+ "sdp": {
+ "id": "sdp-1",
+ "node-id": "N1",
+ "attachment-circuits": {
+ "attachment-circuit": [
+ {"ac-node-id": "R1", "ac-tp-id": "Eth1"}
+ ]
+ }
+ }
+ }
+ ]
+ }
+ res = l2vpn(intent)
+ assert res is not None
+ assert "ietf-l2vpn-svc:l2vpn-svc" in res
+ assert len(res["ietf-l2vpn-svc:l2vpn-svc"]["sites"]["site"]) == 1
+
+ def test_l3vpn_realization_valid_sdps(self):
+ from src.realizer.restconf.service_types.l3vpn import l3vpn
+
+ intent = {
+ "id": "l3-slice-1",
+ "connectivity_type": "point-to-point",
+ "sdps": [
+ {
+ "sdp": {
+ "id": "sdp-1",
+ "node-id": "N1",
+ "attachment-circuits": {
+ "attachment-circuit": [
+ {"ac-node-id": "R1", "ac-tp-id": "Eth1", "ac-ipv4-address": "10.0.0.1", "ac-ipv4-prefix-length": 24}
+ ]
+ }
+ }
+ }
+ ]
+ }
+ res = l3vpn(intent)
+ assert res is not None
+ assert "ietf-l3vpn-svc:l3vpn-svc" in res
+ assert len(res["ietf-l3vpn-svc:l3vpn-svc"]["sites"]["site"]) == 1
+
+ def test_initialize_structure(self):
+ from src.realizer.restconf.service_types.builders.initialize_structure import initialize_structure
+
+ l2_struct = initialize_structure("vpn-l2", "point-to-point", layer_type="l2")
+ assert "ietf-l2vpn-svc:l2vpn-svc" in l2_struct
+ assert l2_struct["ietf-l2vpn-svc:l2vpn-svc"]["vpn-services"]["vpn-service"][0]["ce-vlan-preservation"] is False
+
+ l3_struct = initialize_structure("vpn-l3", "point-to-point", layer_type="l3")
+ assert "ietf-l3vpn-svc:l3vpn-svc" in l3_struct
+
+ def test_create_network_access_roles_and_layers(self):
+ from src.realizer.restconf.service_types.builders.create_network_access import create_network_access
+
+ sdp_sender = {"type": "sender", "sdp": {"id": "sdp-1"}}
+ sdp_receiver = {"type": "receiver", "sdp": {"id": "sdp-2"}}
+ sdp_any = {"sdp": {"id": "sdp-3"}}
+
+ # Hub-Spoke roles
+ na_hub = create_network_access(sdp_sender, {"id": "v-1"}, "ietf-vpn-common:hub-spoke", "R1", "Eth1", "l3")
+ assert na_hub["vpn-attachment"]["site-role"] == "hub-role"
+
+ na_spoke = create_network_access(sdp_receiver, {"id": "v-1"}, "ietf-vpn-common:hub-spoke", "R1", "Eth1", "l3")
+ assert na_spoke["vpn-attachment"]["site-role"] == "spoke-role"
+
+ with pytest.raises(ValueError, match="Unsupported site_type"):
+ create_network_access(sdp_any, {"id": "v-1"}, "ietf-vpn-common:hub-spoke", "R1", "Eth1", "l3")
+
+ # Any-to-Any role
+ na_any = create_network_access(sdp_any, {"id": "v-1"}, "ietf-vpn-common:any-to-any", "R1", "Eth1", "l2")
+ assert na_any["vpn-attachment"]["site-role"] == "any-to-any-role"
+ assert "bearer" in na_any
+
+ # Unsupported layer
+ with pytest.raises(ValueError, match="Unsupported layer_type"):
+ create_network_access(sdp_any, {"id": "v-1"}, "point-to-point", "R1", "Eth1", "l4")
+
+ def test_configure_match_criteria_variants(self):
+ from src.realizer.restconf.service_types.builders.configure_match_criteria import configure_match_criteria
+
+ net_access = {"service": {"qos": {"qos-classification-policy": {"rule": []}}}, "connection": {"tagged-interface": {"dot1q-vlan-tagged": {}}}}
+ site = {}
+
+ # None match criteria
+ configure_match_criteria(net_access, site, {}, "l2")
+
+ # Match type any -> no rule added
+ configure_match_criteria(net_access, site, {"match_criteria": {"match-type": [{"type": "any"}]}}, "l2")
+ assert len(net_access["service"]["qos"]["qos-classification-policy"]["rule"]) == 0
+
+ # L3 vlan match criteria -> static routing protocol
+ sdp_vlan = {
+ "match_criteria": {"index": 1, "match-type": [{"type": "vlan", "vlan": [100]}]},
+ "sdp": {"attachment-circuits": {"attachment-circuit": [{"ac-ipv4-address": "10.0.0.1", "ac-ipv4-prefix-length": 24}]}}
+ }
+ configure_match_criteria(net_access, site, sdp_vlan, "l3")
+ assert "routing-protocols" in site
+ assert site["routing-protocols"]["routing-protocol"][0]["static"]["cascaded-lan-prefixes"]["ipv4-lan-prefixes"][0]["lan-tag"] == 100
+
+ # L2 vlan match criteria
+ configure_match_criteria(net_access, site, sdp_vlan, "l2")
+ assert net_access["connection"]["tagged-interface"]["dot1q-vlan-tagged"]["cvlan-id"] == 100
+
+ # DSCP, source-ip-prefix, destination-ip-prefix, unknown
+ sdp_dscp = {"match_criteria": {"index": 2, "match-type": [{"type": "dscp", "dscp": [46]}]}}
+ configure_match_criteria(net_access, site, sdp_dscp, "l3")
+
+ sdp_src_ip = {"match_criteria": {"index": 3, "match-type": [{"type": "source-ip-prefix", "source-ip-prefix": ["192.168.1.0/24"]}]}}
+ configure_match_criteria(net_access, site, sdp_src_ip, "l3")
+
+ sdp_dst_ip = {"match_criteria": {"index": 4, "match-type": [{"type": "destination-ip-prefix", "destination-ip-prefix": ["10.1.1.0/24"]}]}}
+ configure_match_criteria(net_access, site, sdp_dst_ip, "l3")
+
+ sdp_unknown = {"match_criteria": {"index": 5, "match-type": [{"type": "invalid-type", "invalid-type": [1]}]}}
+ configure_match_criteria(net_access, site, sdp_unknown, "l3")
+
+ def test_configure_slos_and_apply_metric_constraint(self):
+ from src.realizer.restconf.service_types.builders.configure_slos import configure_slos
+
+ net_access_l2 = {
+ "service": {
+ "qos": {
+ "qos-profile": {
+ "classes": {
+ "class": [{"class-id": "qos-realtime"}]
+ }
+ }
+ }
+ }
+ }
+ intent_full = {
+ "id": "vpn-1",
+ "template": {
+ "slo-policy": {
+ "availability": 99.9,
+ "mtu": 1400,
+ "metric-bound": [
+ {"metric-type": "two-way-bandwidth", "bound": 10, "metric-unit": "Mbps"},
+ {"metric-type": "two-way-delay-maximum", "bound": 20},
+ {"metric-type": "two-way-delay-variation-maximum", "bound": 5},
+ {"metric-type": "two-way-packet-loss", "bound": 0.01}
+ ]
+ }
+ }
+ }
+ configure_slos(net_access_l2, intent_full, "l2")
+ assert net_access_l2["service"]["svc-mtu"] == 1400
+ assert net_access_l2["service"]["svc-bandwidth"]["bandwidth"][0]["cir"] == 10_000_000
+
+ # Test L3 constraints and defaults
+ net_access_l3 = {
+ "service": {
+ "qos": {
+ "qos-profile": {
+ "classes": {
+ "class": [{"class-id": "qos-realtime"}]
+ }
+ }
+ }
+ }
+ }
+ intent_units = {
+ "id": "vpn-2",
+ "template": {
+ "slo-policy": {
+ "metric-bound": [
+ {"metric-type": "two-way-bandwidth", "bound": 1, "metric-unit": "Gbps"},
+ {"metric-type": "two-way-bandwidth", "bound": 500, "metric-unit": "kbps"},
+ {"metric-type": "two-way-bandwidth", "bound": 100, "metric-unit": "bps"},
+ {"metric-type": "two-way-delay-maximum", "bound": 15},
+ {"metric-type": "two-way-delay-variation-maximum", "bound": 3}
+ ]
+ }
+ }
+ }
+ configure_slos(net_access_l3, intent_units, "l3")
+ assert net_access_l3["service"]["svc-mtu"] == 1500
+ assert net_access_l3["service"]["qos"]["qos-profile"]["classes"]["class"][0]["bandwidth"]["guaranteed-bw-percent"] == 0
+
+
+class TestRestconfConnect:
+ """Full coverage tests for src/realizer/restconf/restconf_connect.py."""
+
+ def test_restconf_connect_l2vpn_success(self, flask_app):
+ from src.realizer.restconf.restconf_connect import restconf_connect, _slice_manager
+
+ flask_app.config["SDN_CONTROLLER_TYPE"] = "TFS"
+ requests_payload = {
+ "services": [
+ {
+ "ietf-l2vpn-svc:l2vpn-svc": {"vpn-services": {}}
+ }
+ ]
+ }
+
+ mock_resp = MagicMock()
+ mock_resp.ok = True
+
+ with flask_app.app_context(), \
+ patch("src.realizer.restconf.restconf_connect.tfs_connector") as mock_conn_cls:
+ mock_conn = MagicMock()
+ mock_conn.nbi_post.return_value = mock_resp
+ mock_conn_cls.return_value = mock_conn
+
+ res = restconf_connect(requests_payload, "10.0.0.1")
+ assert res == mock_resp
+
+ def test_restconf_connect_l3vpn_frr_branch(self, flask_app):
+ from src.realizer.restconf.restconf_connect import restconf_connect, _slice_manager
+
+ flask_app.config["SDN_CONTROLLER_TYPE"] = "TFS"
+ flask_app.config["DATAPLANE_SUPPORT"] = "FRR"
+
+ l3_service = {
+ "ietf-l3vpn-svc:l3vpn-svc": {}
+ }
+
+ mock_resp = MagicMock()
+ mock_resp.ok = True
+
+ with flask_app.app_context(), \
+ patch("src.realizer.restconf.restconf_connect.safe_get", return_value=46), \
+ patch("src.realizer.restconf.restconf_connect.tfs_connector") as mock_conn_cls:
+
+ mock_conn = MagicMock()
+ mock_conn.nbi_post.return_value = mock_resp
+ mock_conn_cls.return_value = mock_conn
+
+ # Case 1: Full slice manager -> 429
+ with patch.object(_slice_manager, "is_full", return_value=True):
+ res_429 = restconf_connect({"services": [l3_service]}, "10.0.0.1")
+ assert res_429[1] == 429
+
+ # Case 2: Assign slot fails -> 429
+ with patch.object(_slice_manager, "is_full", return_value=False), \
+ patch.object(_slice_manager, "assign_slot", return_value=None):
+ res_no_slot = restconf_connect({"services": [l3_service]}, "10.0.0.1")
+ assert res_no_slot[1] == 429
+
+ # Case 3: FRR command execution raises Exception -> 500
+ mock_frr = MagicMock()
+ mock_frr.execute_commands.side_effect = Exception("SSH failure")
+ with patch.object(_slice_manager, "is_full", return_value=False), \
+ patch.object(_slice_manager, "assign_slot", return_value=1), \
+ patch("src.realizer.restconf.restconf_connect.frr_connector", return_value=mock_frr):
+ res_500 = restconf_connect({"services": [l3_service]}, "10.0.0.1")
+ assert res_500[1] == 500
+
+ # Case 4: FRR success -> proceed to nbi_post and return mock_resp
+ mock_frr_ok = MagicMock()
+ mock_frr_ok.execute_commands.return_value = None
+ with patch.object(_slice_manager, "is_full", return_value=False), \
+ patch.object(_slice_manager, "assign_slot", return_value=1), \
+ patch("src.realizer.restconf.restconf_connect.frr_connector", return_value=mock_frr_ok):
+ res_ok = restconf_connect({"services": [l3_service]}, "10.0.0.1")
+ assert res_ok == mock_resp
+
+ def test_restconf_connect_unsupported_type_and_post_failure(self, flask_app):
+ from src.realizer.restconf.restconf_connect import restconf_connect
+
+ flask_app.config["SDN_CONTROLLER_TYPE"] = "TFS"
+
+ # Unsupported service type key -> 400
+ with flask_app.app_context():
+ res_400 = restconf_connect({"services": [{"unsupported-key": {}}]}, "10.0.0.1")
+ assert res_400[1] == 400
+
+ # Post failure -> status_code returned
+ mock_resp_fail = MagicMock()
+ mock_resp_fail.ok = False
+ mock_resp_fail.status_code = 502
+ mock_resp_fail.text = "Bad Gateway"
+
+ with flask_app.app_context(), \
+ patch("src.realizer.restconf.restconf_connect.tfs_connector") as mock_conn_cls:
+ mock_conn = MagicMock()
+ mock_conn.nbi_post.return_value = mock_resp_fail
+ mock_conn_cls.return_value = mock_conn
+
+ res_fail = restconf_connect({"services": [{"ietf-l2vpn-svc:l2vpn-svc": {}}]}, "10.0.0.1")
+ assert res_fail[1] == 502
+
+
+class TestTfsConnector:
+ """Full coverage tests for src/realizer/restconf/connectors/tfs_connector.py."""
+
+ def test_webui_post(self):
+ from src.realizer.restconf.connectors.tfs_connector import tfs_connector
+
+ conn = tfs_connector()
+ mock_get_resp = MagicMock()
+ mock_get_resp.iter_lines.return_value = [b'']
+
+ mock_post_resp = MagicMock()
+ mock_post_resp.text = "OK"
+
+ with patch("requests.Session") as mock_session_cls:
+ mock_session = MagicMock()
+ mock_session.get.return_value = mock_get_resp
+ mock_session.post.return_value = mock_post_resp
+ mock_session_cls.return_value = mock_session
+
+ res = conn.webui_post("10.0.0.1", {"service": 1})
+ assert res == mock_post_resp
+
+ def test_nbi_post_and_delete(self):
+ from src.realizer.restconf.connectors.tfs_connector import tfs_connector
+
+ conn = tfs_connector()
+ mock_resp = MagicMock()
+ mock_resp.text = "OK"
+
+ with patch("requests.Session") as mock_session_cls:
+ mock_session = MagicMock()
+ mock_session.post.return_value = mock_resp
+ mock_session_cls.return_value = mock_session
+
+ res_post = conn.nbi_post("10.0.0.1", {"service": 1}, "path/to/nbi")
+ assert res_post == mock_resp
+
+ with patch("requests.delete") as mock_delete:
+ mock_delete.return_value = mock_resp
+
+ res_del_l2 = conn.nbi_delete("10.0.0.1", "L2", "slice-l2")
+ assert res_del_l2 == mock_resp
+
+ res_del_l3 = conn.nbi_delete("10.0.0.1", "L3", "slice-l3")
+ assert res_del_l3 == mock_resp
+
+ with pytest.raises(ValueError, match="Invalid service type"):
+ conn.nbi_delete("10.0.0.1", "INVALID", "slice-inv")
+
+ def test_get_network_topology(self):
+ from src.realizer.restconf.connectors.tfs_connector import tfs_connector
+
+ conn = tfs_connector()
+ mock_resp = MagicMock()
+ mock_resp.json.return_value = [
+ {
+ "ietf-network:networks": {
+ "network": [{"network-id": "urn:tfs:network:admin", "nodes": []}]
+ }
+ }
+ ]
+
+ with patch("requests.get", return_value=mock_resp):
+ network, code = conn.get_network_topology("10.0.0.1", "slice-1")
+ assert code == 200
+ assert network["network-id"] == "urn:tfs:network:admin"
+
+ def test_extract_stream_uri(self):
+ from src.realizer.restconf.connectors.tfs_connector import tfs_connector
+
+ conn = tfs_connector()
+
+ # Non dict -> None
+ assert conn._extract_stream_uri("not-a-dict", "http://base") is None
+
+ # Direct uri full http
+ assert conn._extract_stream_uri({"uri": "http://stream.com/feed"}, "http://base") == "http://stream.com/feed"
+
+ # Direct uri relative
+ assert conn._extract_stream_uri({"uri": "feed/stream"}, "http://base") == "http://base/feed/stream"
+
+ # Nested in subscription-result
+ nested = {"ietf-subscribed-notifications:subscription-result": {"stream": {"uri": "http://stream.com/nested"}}}
+ assert conn._extract_stream_uri(nested, "http://base") == "http://stream.com/nested"
diff --git a/src/tests/test_utils.py b/src/tests/test_utils.py
index c8da1b37d7ff57f4cc094c012b08a55b9b83ecb9..0a975c159821de6095655ce9c0abbe9d03cc8be9 100644
--- a/src/tests/test_utils.py
+++ b/src/tests/test_utils.py
@@ -207,4 +207,54 @@ def test_build_response_invalid_intent():
result = build_response(bad_intent, response)
except Exception:
result = []
- assert result == []
\ No newline at end of file
+ assert result == []
+
+
+class TestSliceManager:
+ """Tests for SliceManager utility class."""
+
+ def test_slice_manager_assignment_and_release(self):
+ from src.utils.slice_manager import SliceManager
+
+ sm = SliceManager()
+ assert sm.is_full() is False
+
+ # Assign slot for DSCP 46
+ slot1 = sm.assign_slot(46)
+ assert slot1 == 1
+ assert sm.get_active_assignments() == {1: 46}
+
+ # Idempotent assignment
+ slot1_again = sm.assign_slot(46)
+ assert slot1_again == 1
+
+ # Assign slot for DSCP 34
+ slot2 = sm.assign_slot(34)
+ assert slot2 == 2
+ assert sm.is_full() is True
+
+ # Assign when full returns None
+ slot3 = sm.assign_slot(10)
+ assert slot3 is None
+
+ # Release slot
+ released = sm.release_slot(46)
+ assert released is True
+ assert sm.is_full() is False
+ assert sm.get_active_assignments() == {2: 34}
+
+ # Release nonexistent returns False
+ assert sm.release_slot(999) is False
+
+
+class TestSafeGet:
+ """Tests for safe_get utility function."""
+
+ def test_safe_get_nested(self):
+ from src.utils.safe_get import safe_get
+
+ data = {"a": [{"b": {"c": 42}}]}
+ assert safe_get(data, ["a", 0, "b", "c"]) == 42
+ assert safe_get(data, ["a", 1, "b", "c"]) is None
+ assert safe_get(data, ["x", "y"]) is None
+ assert safe_get(None, ["a"]) is None
\ No newline at end of file
diff --git a/src/tests/test_webui.py b/src/tests/test_webui.py
new file mode 100644
index 0000000000000000000000000000000000000000..61a4b4b6f7a9f7edf525d080e3799f67557f86dc
--- /dev/null
+++ b/src/tests/test_webui.py
@@ -0,0 +1,280 @@
+import json
+import pytest
+from unittest.mock import patch, MagicMock
+from flask import Flask
+
+
+@pytest.fixture
+def webui_app():
+ """Create Flask app with gui_bp registered for webui testing."""
+ app = Flask(__name__)
+ app.secret_key = "test-secret-key"
+ app.config["TESTING"] = True
+ app.config["TFS_IP"] = "192.168.1.100"
+ app.config["IXIA_IP"] = "192.168.1.200"
+ app.config["DUMMY_MODE"] = True
+
+ from src.webui.gui import gui_bp
+ app.register_blueprint(gui_bp)
+ return app
+
+
+@pytest.fixture
+def webui_client(webui_app):
+ return webui_app.test_client()
+
+
+class TestWebUIHelpers:
+ """Tests for helper functions in gui.py."""
+
+ def test_safe_int(self):
+ import src.webui.gui as gui_module
+ __safe_int = getattr(gui_module, "__safe_int")
+
+ assert __safe_int("10") == 10
+ assert __safe_int("10.5") == 10.5
+ assert __safe_int("10,5") == 10.5
+ assert __safe_int(42) == 42
+ assert __safe_int(3.14) == 3.14
+ assert __safe_int(None) is None
+ assert __safe_int("invalid") is None
+
+ def test_build_request_ietf(self):
+ import src.webui.gui as gui_module
+ __build_request_ietf = getattr(gui_module, "__build_request_ietf")
+
+ res = __build_request_ietf(
+ src_node_ip="10.0.0.1",
+ dst_node_ip="10.0.0.2",
+ vlan_id="100",
+ bandwidth="1000",
+ latency="10",
+ tolerance="2",
+ latency_version="gaussian",
+ reliability="99"
+ )
+ assert isinstance(res, dict)
+ slice_svc = res["ietf-network-slice-service:network-slice-services"]["slice-service"][0]
+ assert slice_svc["sdps"]["sdp"][0]["sdp-ip-address"] == "10.0.0.1"
+ assert slice_svc["sdps"]["sdp"][1]["sdp-ip-address"] == "10.0.0.2"
+
+ def test_build_request(self):
+ import src.webui.gui as gui_module
+ __build_request = getattr(gui_module, "__build_request")
+
+ res = __build_request(
+ ip_version="IPv4",
+ src_node_ip="10.0.0.1",
+ dst_node_ip="10.0.0.2",
+ vlan_id="100",
+ bandwidth="500"
+ )
+ assert res["ip_version"] == "IPv4"
+ assert res["src_node_ip"] == "10.0.0.1"
+ assert res["bandwidth"] == "500"
+
+ def test_datos_json_file_not_found(self):
+ import src.webui.gui as gui_module
+ __datos_json = getattr(gui_module, "__datos_json")
+
+ with patch("builtins.open", side_effect=FileNotFoundError):
+ df = __datos_json()
+ assert df.empty
+
+
+class TestWebUIRoutes:
+ """Tests for Flask Blueprint webui routes."""
+
+ def test_home_route(self, webui_client):
+ resp = webui_client.get("/webui")
+ assert resp.status_code == 200
+
+ def test_home_route_no_config(self, webui_app):
+ client = webui_app.test_client()
+ webui_app.config.pop("TFS_IP", None)
+ webui_app.config.pop("IXIA_IP", None)
+ resp = client.get("/webui")
+ assert resp.status_code == 200
+
+ def test_login_get_and_post_success(self, webui_client):
+ resp_get = webui_client.get("/webui/login")
+ assert resp_get.status_code == 200
+
+ resp_post = webui_client.post("/webui/login", data={
+ "username": "admin",
+ "password": "admin"
+ })
+ assert resp_post.status_code in [200, 302]
+
+ def test_login_post_invalid(self, webui_client):
+ resp_post = webui_client.post("/webui/login", data={
+ "username": "wrong",
+ "password": "bad"
+ })
+ assert resp_post.status_code == 200
+ assert b"Credenciales incorrectas" in resp_post.data
+
+ def test_develop_unauthenticated(self, webui_client):
+ resp = webui_client.get("/webui/dev")
+ assert resp.status_code == 302
+ assert "/webui/login" in resp.headers["Location"]
+
+ def test_develop_authenticated_get_and_post(self, webui_app):
+ client = webui_app.test_client()
+ with client.session_transaction() as sess:
+ sess["enter"] = True
+
+ resp_get = client.get("/webui/dev?src_node_ip=10.0.0.1&dst_node_ip=10.0.0.2")
+ assert resp_get.status_code == 200
+
+ # POST form submit in DUMMY_MODE
+ resp_post = client.post("/webui/dev", data={
+ "ip_version": "IPv4",
+ "src_node_ipv4": "10.0.0.1",
+ "dst_node_ipv4": "10.0.0.2",
+ "vlan_id": "100",
+ "bandwidth_intent": "1000",
+ "latency_intent": "10"
+ })
+ assert resp_post.status_code == 200
+
+ # POST form submit with DUMMY_MODE = False
+ webui_app.config["DUMMY_MODE"] = False
+ with patch("src.webui.gui.NEII_controller") as mock_neii:
+ resp_post_neii = client.post("/webui/dev", data={
+ "ip_version": "IPv4",
+ "src_node_ipv4": "10.0.0.1",
+ "dst_node_ipv4": "10.0.0.2"
+ })
+ assert resp_post_neii.status_code == 200
+ assert mock_neii.return_value.nscNEII.called
+
+ def test_generate_tfs_get_and_post(self, webui_client):
+ resp_get = webui_client.get("/webui/generate/tfs")
+ assert resp_get.status_code == 200
+
+ mock_resp = MagicMock()
+ mock_resp.ok = True
+ mock_resp.raise_for_status.return_value = None
+
+ with patch("requests.post", return_value=mock_resp):
+ resp_post = webui_client.post("/webui/generate/tfs", data={
+ "src_node_ip": "10.0.0.1",
+ "dst_node_ip": "10.0.0.2",
+ "vlan_id": "100",
+ "latency_intent": "10",
+ "bandwidth_intent": "1000"
+ })
+ assert resp_post.status_code in [200, 302]
+
+ def test_generate_ixia_get_and_post(self, webui_client):
+ resp_get = webui_client.get("/webui/generate/ixia")
+ assert resp_get.status_code == 200
+
+ mock_resp = MagicMock()
+ mock_resp.ok = True
+ mock_resp.raise_for_status.return_value = None
+
+ with patch("requests.post", return_value=mock_resp):
+ resp_post = webui_client.post("/webui/generate/ixia", data={
+ "src_node_ip": "10.0.0.1",
+ "dst_node_ip": "10.0.0.2",
+ "vlan_id": "100",
+ "latency_intent": "10",
+ "bandwidth_intent": "1000",
+ "tolerance_intent": "2",
+ "reliability": "99"
+ })
+ assert resp_post.status_code in [200, 302]
+
+ def test_generate_ixia_error(self, webui_client):
+ import requests
+ with patch("requests.post", side_effect=requests.RequestException("Conn error")):
+ resp_post = webui_client.post("/webui/generate/ixia", data={
+ "src_node_ip": "10.0.0.1",
+ "dst_node_ip": "10.0.0.2",
+ "vlan_id": "100",
+ "latency_intent": "10",
+ "bandwidth_intent": "1000",
+ "tolerance_intent": "2",
+ "reliability": "99"
+ })
+ assert resp_post.status_code == 200
+ assert b"Intent Generation Error" in resp_post.data
+
+ def test_search_get_and_post_filters(self, webui_client):
+ sample_slice = [{
+ "controller": "TFS",
+ "intent": {
+ "ietf-network-slice-service:network-slice-services": {
+ "slice-service": [{
+ "sdps": {
+ "sdp": [
+ {
+ "sdp-ip-address": "10.0.0.1",
+ "service-match-criteria": {
+ "match-criterion": [{"match-type": [{"vlan": [100]}]}]
+ }
+ },
+ {
+ "sdp-ip-address": "10.0.0.2",
+ "service-match-criteria": {
+ "match-criterion": [{"match-type": [{"vlan": [100]}]}]
+ }
+ }
+ ]
+ }
+ }],
+ "slo-sle-templates": {
+ "slo-sle-template": [{
+ "slo-policy": {
+ "metric-bound": [
+ {"metric-type": "one-way-bandwidth", "bound": 1000, "metric-unit": "Mbps"},
+ {"metric-type": "one-way-delay-maximum", "bound": 10, "metric-unit": "ms"},
+ {"metric-type": "one-way-delay-variation-maximum", "bound": 2, "metric-unit": "ms"}
+ ]
+ }
+ }]
+ }
+ }
+ }
+ }]
+
+ mock_get_resp = MagicMock()
+ mock_get_resp.json.return_value = sample_slice
+ mock_get_resp.raise_for_status.return_value = None
+
+ with patch("requests.get", return_value=mock_get_resp):
+ resp_get = webui_client.get("/webui/search")
+ assert resp_get.status_code == 200
+
+ # Test POST search filters
+ for opt, val in [("Source IP", "10.0.0.1"), ("Destiny IP", "10.0.0.2"), ("Controller", "TFS"), ("VLAN", 100)]:
+ resp_post = webui_client.post("/webui/search", data={
+ "search_option": opt,
+ "search_value": str(val)
+ })
+ assert resp_post.status_code == 200
+ data = json.loads(resp_post.data)
+ assert "result" in data
+
+ def test_search_request_exception(self, webui_client):
+ import requests
+ with patch("requests.get", side_effect=requests.RequestException("API error")):
+ resp = webui_client.get("/webui/search")
+ assert resp.status_code == 200
+
+ def test_reset_route(self, webui_client):
+ resp = webui_client.post("/webui/reset")
+ assert resp.status_code == 200
+ data = json.loads(resp.data)
+ assert "result" in data
+
+ def test_update_ips_route(self, webui_client, tmp_path):
+ with patch("os.path.exists", return_value=False), \
+ patch("src.webui.gui.SRC_PATH", str(tmp_path)):
+ resp = webui_client.post("/webui/update_ips", json={
+ "tfs_ip": "10.10.10.10",
+ "ixia_ip": "10.10.10.20"
+ })
+ assert resp.status_code == 200
diff --git a/src/utils/build_response.py b/src/utils/build_response.py
index d3d283746cc75e5517eebea4c6a7252546cb76d8..ccaee3ccfb2524db87200695cc5691339f665984 100644
--- a/src/utils/build_response.py
+++ b/src/utils/build_response.py
@@ -62,6 +62,9 @@ def build_response(intent, response, controller_type = None):
p2mp_sender = cc.get("p2mp-sender-sdp")
if cc.get("p2mp-receiver-sdp"):
p2mp_receivers = cc.get("p2mp-receiver-sdp")
+ if cc.get("p2mp-sdp"):
+ p2mp_sender = cc.get("p2mp-sdp", {}).get("root-sdp-id")
+ p2mp_receivers = cc.get("p2mp-sdp", {}).get("leaf-sdp-id", [])
if p2mp_sender:
source = p2mp_sender