From 334ac0d17a54f22c6c97d00905a8ffd78763b536 Mon Sep 17 00:00:00 2001 From: velazquez Date: Thu, 6 Aug 2026 10:50:00 +0200 Subject: [PATCH 1/4] Organize requests --- src/templates/ietf_template.json | 174 ++++++++++ .../nbi_template.json} | 98 +++--- src/templates/realizer_template.json | 37 +++ .../3gpp_template_UC1PoC2_backhaul.json | 0 ...p_template_UC1PoC2_backhaul_request_1.json | 0 ...p_template_UC1PoC2_backhaul_request_2.json | 0 ...p_template_UC1PoC2_backhaul_request_3.json | 0 .../3gpp_template_UC1PoC2_midhaul.json | 0 ...mplate_UC1PoC2_backhaul 1flow_request.json | 131 ++++++++ ...mplate_UC1PoC2_backhaul_2flow_request.json | 199 ++++++++++++ .../{ => 6Green}/3ggpp_template_green.json | 22 +- .../{ => Allegro-Season-Proteus}/P2MP.json | 0 .../create_slice_1.json | 0 src/tests/requests/General/l2vpn_test.json | 220 +++++++++++++ .../requests/{ => General}/l3vpn_test.json | 31 +- .../requests/Hackfest/slice_request 2.json | 168 ++++++++++ .../{ => Hackfest}/slice_request.json | 0 .../request-nsc-test emulated dummy-topo.json | 220 +++++++++++++ .../request-nsc-test eucnc24.json | 220 +++++++++++++ .../requests/Joint SDG lab/request-nsc.json | 220 +++++++++++++ src/tests/requests/SIMAP/test_simap.json | 296 ++++++++++++++++++ src/tests/requests/SIMAP/test_simap_2.json | 296 ++++++++++++++++++ .../{ => Timing}/ietf_template_timing.json | 0 .../requests/{ => Unity-6G}/frr_request.json | 0 24 files changed, 2252 insertions(+), 80 deletions(-) create mode 100644 src/templates/ietf_template.json rename src/{tests/requests/ietf_green_request.json => templates/nbi_template.json} (64%) create mode 100644 src/templates/realizer_template.json rename src/tests/requests/{ => 6GBlur}/3gpp_template_UC1PoC2_backhaul.json (100%) rename src/tests/requests/{ => 6GBlur}/3gpp_template_UC1PoC2_backhaul_request_1.json (100%) rename src/tests/requests/{ => 6GBlur}/3gpp_template_UC1PoC2_backhaul_request_2.json (100%) rename src/tests/requests/{ => 6GBlur}/3gpp_template_UC1PoC2_backhaul_request_3.json (100%) rename src/tests/requests/{ => 6GBlur}/3gpp_template_UC1PoC2_midhaul.json (100%) create mode 100644 src/tests/requests/6GBlur/Demo Jordi/3gpp_template_UC1PoC2_backhaul 1flow_request.json create mode 100644 src/tests/requests/6GBlur/Demo Jordi/3gpp_template_UC1PoC2_backhaul_2flow_request.json rename src/tests/requests/{ => 6Green}/3ggpp_template_green.json (93%) rename src/tests/requests/{ => Allegro-Season-Proteus}/P2MP.json (100%) rename src/tests/requests/{ => Allegro-Season-Proteus}/create_slice_1.json (100%) create mode 100644 src/tests/requests/General/l2vpn_test.json rename src/tests/requests/{ => General}/l3vpn_test.json (88%) create mode 100644 src/tests/requests/Hackfest/slice_request 2.json rename src/tests/requests/{ => Hackfest}/slice_request.json (100%) create mode 100644 src/tests/requests/Joint SDG lab/request-nsc-test emulated dummy-topo.json create mode 100644 src/tests/requests/Joint SDG lab/request-nsc-test eucnc24.json create mode 100644 src/tests/requests/Joint SDG lab/request-nsc.json create mode 100644 src/tests/requests/SIMAP/test_simap.json create mode 100644 src/tests/requests/SIMAP/test_simap_2.json rename src/tests/requests/{ => Timing}/ietf_template_timing.json (100%) rename src/tests/requests/{ => Unity-6G}/frr_request.json (100%) diff --git a/src/templates/ietf_template.json b/src/templates/ietf_template.json new file mode 100644 index 0000000..74492a7 --- /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 b016503..3110ebf 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 0000000..f5ee205 --- /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/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 0000000..d9f5e2a --- /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 0000000..2e40c8e --- /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 67a1367..2330a81 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 0000000..8db0757 --- /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 e3019e5..2c45086 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 0000000..62d9088 --- /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 0000000..fe76c10 --- /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 0000000..b6a9cfb --- /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 0000000..e12307a --- /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 0000000..ef90856 --- /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 0000000..723055c --- /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 -- GitLab From 35c2a2547c03a9ac236b216cb48bb2a0a6567882 Mon Sep 17 00:00:00 2001 From: velazquez Date: Thu, 6 Aug 2026 10:51:16 +0200 Subject: [PATCH 2/4] Update ignores --- .dockerignore | 11 ++++++++++- .gitignore | 3 +++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/.dockerignore b/.dockerignore index 7943f8f..5ccb441 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 5207b65..7f02922 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,7 @@ src/__pycache__/ venv/ .env slice.db +service.db +telemetry_client.db +alert.db .python-version -- GitLab From 5e6a6b46e7e5146d8b6c2ca82378fe68a9c30162 Mon Sep 17 00:00:00 2001 From: velazquez Date: Thu, 6 Aug 2026 10:52:00 +0200 Subject: [PATCH 3/4] Minor changes in code to adapt it for tests --- src/api/main.py | 32 +++++++++------- src/database/service_db.py | 42 +++++++++++---------- src/database/sysrepo_store.py | 26 ++++++++++--- src/mapper/main.py | 4 +- src/mapper/process_connnectivity.py | 3 +- src/realizer/restconf/restconf_connect.py | 5 +++ src/realizer/tfs/service_types/tfs_l2vpn.py | 36 ++++++++++-------- src/utils/build_response.py | 3 ++ 8 files changed, 95 insertions(+), 56 deletions(-) diff --git a/src/api/main.py b/src/api/main.py index 6124853..f28ebed 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 e897bab..0e2aa1f 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 15cb874..4a41286 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 a936dfa..30577e2 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 b057ce1..8dc1209 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 9678e4f..e9d880f 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 e2a9a66..9725459 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/utils/build_response.py b/src/utils/build_response.py index d3d2837..ccaee3c 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 -- GitLab From 5ed6719a32e79b146ea3e7c24ab68dc9975d2fb3 Mon Sep 17 00:00:00 2001 From: velazquez Date: Thu, 6 Aug 2026 10:52:36 +0200 Subject: [PATCH 4/4] Extend tests to improve code coverage --- src/tests/conftest.py | 204 ++++ src/tests/test_api.py | 1710 +++++++++++++++++++++++------ src/tests/test_database.py | 143 ++- src/tests/test_e2e.py | 377 +++++-- src/tests/test_initialization.py | 72 +- src/tests/test_mapper.py | 1739 ++++++++++++++++++------------ src/tests/test_namespaces.py | 461 ++++++++ src/tests/test_nbi_processor.py | 9 + src/tests/test_planner.py | 307 ++++++ src/tests/test_realizer.py | 682 ++++++++++++ src/tests/test_utils.py | 52 +- src/tests/test_webui.py | 280 +++++ 12 files changed, 4889 insertions(+), 1147 deletions(-) create mode 100644 src/tests/conftest.py create mode 100644 src/tests/test_namespaces.py create mode 100644 src/tests/test_planner.py create mode 100644 src/tests/test_realizer.py create mode 100644 src/tests/test_webui.py diff --git a/src/tests/conftest.py b/src/tests/conftest.py new file mode 100644 index 0000000..35a6135 --- /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/test_api.py b/src/tests/test_api.py index 61d5151..e1234dc 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 9ca92c3..7b1c0ce 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 ce2e3e8..6efce97 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 629d590..f2e45f0 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 79c9f83..65bb0d6 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 0000000..166fc4c --- /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 fecb109..29f715f 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 0000000..32837f5 --- /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 0000000..134899b --- /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 c8da1b3..0a975c1 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 0000000..61a4b4b --- /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 -- GitLab