From 10940d16a18d1667d74b02680917ea09d43696dd Mon Sep 17 00:00:00 2001
From: Karel van Klink <karel.vanklink@geant.org>
Date: Thu, 25 Apr 2024 12:46:58 +0200
Subject: [PATCH] Remove API endpoints for importing subscriptions, and move
 all functionality into the CLI

---
 gso/api/helpers.py                     |  34 --
 gso/api/v1/__init__.py                 |   2 -
 gso/api/v1/imports/__init__.py         |  19 --
 gso/api/v1/imports/iptrunk.py          | 110 -------
 gso/api/v1/imports/office_router.py    |  39 ---
 gso/api/v1/imports/router.py           |  43 ---
 gso/api/v1/imports/site.py             |  43 ---
 gso/api/v1/imports/super_pop_switch.py |  38 ---
 gso/cli/imports.py                     | 269 +++++++++++-----
 requirements.txt                       |   1 +
 setup.py                               |   1 +
 test/api/test_imports.py               | 426 -------------------------
 test/cli/__init__.py                   |   0
 test/cli/conftest.py                   |   8 +
 test/cli/test_imports.py               | 350 ++++++++++++++++++++
 15 files changed, 557 insertions(+), 826 deletions(-)
 delete mode 100644 gso/api/helpers.py
 delete mode 100644 gso/api/v1/imports/__init__.py
 delete mode 100644 gso/api/v1/imports/iptrunk.py
 delete mode 100644 gso/api/v1/imports/office_router.py
 delete mode 100644 gso/api/v1/imports/router.py
 delete mode 100644 gso/api/v1/imports/site.py
 delete mode 100644 gso/api/v1/imports/super_pop_switch.py
 delete mode 100644 test/api/test_imports.py
 create mode 100644 test/cli/__init__.py
 create mode 100644 test/cli/conftest.py
 create mode 100644 test/cli/test_imports.py

diff --git a/gso/api/helpers.py b/gso/api/helpers.py
deleted file mode 100644
index 56b1586d..00000000
--- a/gso/api/helpers.py
+++ /dev/null
@@ -1,34 +0,0 @@
-"""Helper methods for the :term:`API` module."""
-
-from uuid import UUID
-
-from fastapi import HTTPException
-from orchestrator.services import processes
-from pydantic import BaseModel
-from starlette import status
-
-
-def _start_process(process_name: str, data: dict) -> UUID:
-    """Start a process and handle common exceptions."""
-    pid: UUID = processes.start_process(process_name, [data])
-    if pid is None:
-        raise HTTPException(
-            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
-            detail="Failed to start the process.",
-        )
-
-    process = processes._get_process(pid)  # noqa: SLF001
-    if process.last_status == "failed":
-        raise HTTPException(
-            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
-            detail=f"Process {pid} failed because of an internal error. {process.failed_reason}",
-        )
-
-    return pid
-
-
-class ImportResponseModel(BaseModel):
-    """The model of a response given when services are imported using the :term:`API`."""
-
-    pid: UUID
-    detail: str
diff --git a/gso/api/v1/__init__.py b/gso/api/v1/__init__.py
index b48bc2e0..5407a816 100644
--- a/gso/api/v1/__init__.py
+++ b/gso/api/v1/__init__.py
@@ -2,14 +2,12 @@
 
 from fastapi import APIRouter
 
-from gso.api.v1.imports import api_router as imports_router
 from gso.api.v1.network import router as network_router
 from gso.api.v1.processes import router as processes_router
 from gso.api.v1.subscriptions import router as subscriptions_router
 
 router = APIRouter()
 
-router.include_router(imports_router)
 router.include_router(subscriptions_router)
 router.include_router(processes_router)
 router.include_router(network_router)
diff --git a/gso/api/v1/imports/__init__.py b/gso/api/v1/imports/__init__.py
deleted file mode 100644
index 23397d2b..00000000
--- a/gso/api/v1/imports/__init__.py
+++ /dev/null
@@ -1,19 +0,0 @@
-""":term:`API` routes for adding existing products to the database."""
-
-from fastapi import Depends
-from fastapi.routing import APIRouter
-
-from gso.api.v1.imports.iptrunk import router as iptrunk_router
-from gso.api.v1.imports.office_router import router as office_router_router
-from gso.api.v1.imports.router import router as router_router
-from gso.api.v1.imports.site import router as site_router
-from gso.api.v1.imports.super_pop_switch import router as super_pop_switch_router
-from gso.auth.security import opa_security_default
-
-api_router = APIRouter(prefix="/imports", tags=["Imports"], dependencies=[Depends(opa_security_default)])
-
-api_router.include_router(iptrunk_router)
-api_router.include_router(office_router_router)
-api_router.include_router(router_router)
-api_router.include_router(site_router)
-api_router.include_router(super_pop_switch_router)
diff --git a/gso/api/v1/imports/iptrunk.py b/gso/api/v1/imports/iptrunk.py
deleted file mode 100644
index 25da71b2..00000000
--- a/gso/api/v1/imports/iptrunk.py
+++ /dev/null
@@ -1,110 +0,0 @@
-""":term:`API` endpoints for importing IP trunks."""
-
-import ipaddress
-from typing import Any
-
-from fastapi import APIRouter, status
-from pydantic import BaseModel, root_validator, validator
-
-from gso.api.helpers import ImportResponseModel, _start_process
-from gso.products.product_blocks.iptrunk import IptrunkType, PhysicalPortCapacity
-from gso.services import subscriptions
-from gso.services.partners import PartnerNotFoundError, get_partner_by_name
-from gso.utils.helpers import LAGMember
-
-router = APIRouter()
-
-
-class IptrunkImportModel(BaseModel):
-    """Required fields for importing an existing :class:`gso.products.product_types.iptrunk`."""
-
-    partner: str
-    geant_s_sid: str | None
-    iptrunk_type: IptrunkType
-    iptrunk_description: str
-    iptrunk_speed: PhysicalPortCapacity
-    iptrunk_minimum_links: int
-    iptrunk_isis_metric: int
-    side_a_node_id: str
-    side_a_ae_iface: str
-    side_a_ae_geant_a_sid: str | None
-    side_a_ae_members: list[LAGMember]
-    side_b_node_id: str
-    side_b_ae_iface: str
-    side_b_ae_geant_a_sid: str | None
-    side_b_ae_members: list[LAGMember]
-
-    iptrunk_ipv4_network: ipaddress.IPv4Network
-    iptrunk_ipv6_network: ipaddress.IPv6Network
-
-    @classmethod
-    def _get_active_routers(cls) -> set[str]:
-        return {
-            str(router["subscription_id"])
-            for router in subscriptions.get_active_router_subscriptions(includes=["subscription_id"])
-        }
-
-    @validator("partner")
-    def check_if_partner_exists(cls, value: str) -> str:
-        """Validate that the partner exists."""
-        try:
-            get_partner_by_name(value)
-        except PartnerNotFoundError as e:
-            msg = f"partner {value} not found"
-            raise ValueError(msg) from e
-
-        return value
-
-    @validator("side_a_node_id", "side_b_node_id")
-    def check_if_router_side_is_available(cls, value: str) -> str:
-        """Both sides of the trunk must exist in :term:`GSO`."""
-        if value not in cls._get_active_routers():
-            msg = f"Router {value} not found"
-            raise ValueError(msg)
-
-        return value
-
-    @validator("side_a_ae_members", "side_b_ae_members")
-    def check_side_uniqueness(cls, value: list[str]) -> list[str]:
-        """:term:`LAG` members must be unique."""
-        if len(value) != len(set(value)):
-            msg = "Items must be unique"
-            raise ValueError(msg)
-
-        return value
-
-    @root_validator
-    def check_members(cls, values: dict[str, Any]) -> dict[str, Any]:
-        """Amount of :term:`LAG` members has to match on side A and B, and meet the minimum requirement."""
-        min_links = values["iptrunk_minimum_links"]
-        side_a_members = values.get("side_a_ae_members", [])
-        side_b_members = values.get("side_b_ae_members", [])
-
-        len_a = len(side_a_members)
-        len_b = len(side_b_members)
-
-        if len_a < min_links:
-            msg = f"Side A members should be at least {min_links} (iptrunk_minimum_links)"
-            raise ValueError(msg)
-
-        if len_a != len_b:
-            msg = "Mismatch between Side A and B members"
-            raise ValueError(msg)
-
-        return values
-
-
-@router.post("/iptrunks", status_code=status.HTTP_201_CREATED, response_model=ImportResponseModel)
-def create_imported_iptrunk(iptrunk_data: IptrunkImportModel) -> dict[str, Any]:
-    """Import an iptrunk by running the create_imported_iptrunk workflow.
-
-    :param iptrunk_data: The iptrunk information to be imported.
-    :type iptrunk_data: IptrunkImportModel
-
-    :return: A dictionary containing the process id of the started process and detail message.
-    :rtype: dict[str, Any]
-
-    :raises HTTPException: If there's an error in the process.
-    """
-    pid = _start_process("create_imported_iptrunk", iptrunk_data.dict())
-    return {"detail": "Iptrunk has been added successfully", "pid": pid}
diff --git a/gso/api/v1/imports/office_router.py b/gso/api/v1/imports/office_router.py
deleted file mode 100644
index faf6e030..00000000
--- a/gso/api/v1/imports/office_router.py
+++ /dev/null
@@ -1,39 +0,0 @@
-""":term:`API` endpoints for importing office Routers."""
-
-import ipaddress
-from typing import Any
-
-from fastapi import APIRouter, status
-from pydantic import BaseModel
-
-from gso.api.helpers import ImportResponseModel, _start_process
-from gso.utils.shared_enums import PortNumber
-
-router = APIRouter()
-
-
-class OfficeRouterImportModel(BaseModel):
-    """Required fields for importing an existing :class:`gso.product.product_types.office_router`."""
-
-    partner: str
-    office_router_site: str
-    office_router_fqdn: str
-    office_router_ts_port: PortNumber
-    office_router_lo_ipv4_address: ipaddress.IPv4Address
-    office_router_lo_ipv6_address: ipaddress.IPv6Address
-
-
-@router.post("/office-routers", status_code=status.HTTP_201_CREATED, response_model=ImportResponseModel)
-def create_imported_office_router(office_router_data: OfficeRouterImportModel) -> dict[str, Any]:
-    """Import an office router by running the create_imported_office_router workflow.
-
-    :param office_router_data: The office router information to be imported.
-    :type office_router_data: OfficeRouterImportModel
-
-    :return: A dictionary containing the process id of the started process and detail message.
-    :rtype: dict[str, Any]
-
-    :raises HTTPException: If there's an error in the process.
-    """
-    pid = _start_process("create_imported_office_router", office_router_data.dict())
-    return {"detail": "Office router has been added successfully", "pid": pid}
diff --git a/gso/api/v1/imports/router.py b/gso/api/v1/imports/router.py
deleted file mode 100644
index 63e5c0ee..00000000
--- a/gso/api/v1/imports/router.py
+++ /dev/null
@@ -1,43 +0,0 @@
-""":term:`API` endpoints for importing Routers."""
-
-import ipaddress
-from typing import Any
-
-from fastapi import APIRouter, status
-from pydantic import BaseModel
-
-from gso.api.helpers import ImportResponseModel, _start_process
-from gso.products.product_blocks.router import RouterRole
-from gso.utils.shared_enums import Vendor
-
-router = APIRouter()
-
-
-class RouterImportModel(BaseModel):
-    """Required fields for importing an existing :class:`gso.product.product_types.router`."""
-
-    partner: str
-    router_site: str
-    hostname: str
-    ts_port: int
-    router_vendor: Vendor
-    router_role: RouterRole
-    router_lo_ipv4_address: ipaddress.IPv4Address
-    router_lo_ipv6_address: ipaddress.IPv6Address
-    router_lo_iso_address: str
-
-
-@router.post("/routers", status_code=status.HTTP_201_CREATED, response_model=ImportResponseModel)
-def create_imported_router(router_data: RouterImportModel) -> dict[str, Any]:
-    """Import a router by running the create_imported_router workflow.
-
-    :param router_data: The router information to be imported.
-    :type router_data: RouterImportModel
-
-    :return: A dictionary containing the process id of the started process and detail message.
-    :rtype: dict[str, Any]
-
-    :raises HTTPException: If there's an error in the process.
-    """
-    pid = _start_process("create_imported_router", router_data.dict())
-    return {"detail": "Router has been added successfully", "pid": pid}
diff --git a/gso/api/v1/imports/site.py b/gso/api/v1/imports/site.py
deleted file mode 100644
index b1118c2e..00000000
--- a/gso/api/v1/imports/site.py
+++ /dev/null
@@ -1,43 +0,0 @@
-""":term:`API` endpoints for importing Sites."""
-
-from typing import Any
-
-from fastapi import APIRouter, status
-
-from gso.api.helpers import ImportResponseModel, _start_process
-from gso.products.product_blocks.site import SiteTier
-from gso.utils.helpers import BaseSiteValidatorModel
-
-router = APIRouter()
-
-
-class SiteImportModel(BaseSiteValidatorModel):
-    """The required input for importing an existing :class:`gso.products.product_types.site`."""
-
-    site_name: str
-    site_city: str
-    site_country: str
-    site_country_code: str
-    site_latitude: float
-    site_longitude: float
-    site_bgp_community_id: int
-    site_internal_id: int
-    site_tier: SiteTier
-    site_ts_address: str
-    partner: str
-
-
-@router.post("/sites", status_code=status.HTTP_201_CREATED, response_model=ImportResponseModel)
-def create_imported_site(site: SiteImportModel) -> dict[str, Any]:
-    """Import a site by running the create_imported_site workflow.
-
-    :param site: The site information to be imported.
-    :type site: SiteImportModel
-
-    :return: A dictionary containing the process id of the started process and detail message.
-    :rtype: dict[str, Any]
-
-    :raises HTTPException: If the site already exists or if there's an error in the process.
-    """
-    pid = _start_process("create_imported_site", site.dict())
-    return {"detail": "Site added successfully.", "pid": pid}
diff --git a/gso/api/v1/imports/super_pop_switch.py b/gso/api/v1/imports/super_pop_switch.py
deleted file mode 100644
index 926f7e6d..00000000
--- a/gso/api/v1/imports/super_pop_switch.py
+++ /dev/null
@@ -1,38 +0,0 @@
-""":term:`API` endpoints for importing Super PoP switches."""
-
-import ipaddress
-from typing import Any
-
-from fastapi import APIRouter, status
-from pydantic import BaseModel
-
-from gso.api.helpers import ImportResponseModel, _start_process
-from gso.utils.shared_enums import PortNumber
-
-router = APIRouter()
-
-
-class SuperPopSwitchImportModel(BaseModel):
-    """Required fields for importing an existing :class:`gso.product.product_types.super_pop_switch`."""
-
-    partner: str
-    super_pop_switch_site: str
-    hostname: str
-    super_pop_switch_ts_port: PortNumber
-    super_pop_switch_mgmt_ipv4_address: ipaddress.IPv4Address
-
-
-@router.post("/super-pop-switches", status_code=status.HTTP_201_CREATED, response_model=ImportResponseModel)
-def create_imported_super_pop_switch(super_pop_switch_data: SuperPopSwitchImportModel) -> dict[str, Any]:
-    """Import a Super PoP switch by running the create_imported_super_pop_switch workflow.
-
-    :param super_pop_switch_data: The Super PoP switch information to be imported.
-    :type super_pop_switch_data: SuperPopSwitchImportModel
-
-    :return: A dictionary containing the process id of the started process and detail message.
-    :rtype: dict[str, Any]
-
-    :raises HTTPException: If there's an error in the process.
-    """
-    pid = _start_process("create_imported_super_pop_switch", super_pop_switch_data.dict())
-    return {"detail": "Super PoP switch has been added successfully", "pid": pid}
diff --git a/gso/cli/imports.py b/gso/cli/imports.py
index e40e706c..6967eb2d 100644
--- a/gso/cli/imports.py
+++ b/gso/cli/imports.py
@@ -1,31 +1,166 @@
-""":term:`CLI` command for importing data to coreDB."""
+""":term:`CLI` commands for importing data to coreDB."""
 
 import csv
 import ipaddress
 import json
 from datetime import UTC, datetime
 from pathlib import Path
-from typing import TypeVar
+from typing import Any, TypeVar
 
 import typer
 import yaml
 from orchestrator.db import db
 from orchestrator.services.processes import start_process
 from orchestrator.types import SubscriptionLifecycle
-from pydantic import ValidationError
+from pydantic import BaseModel, ValidationError, root_validator, validator
 from sqlalchemy.exc import SQLAlchemyError
 
-from gso.api.v1.imports.iptrunk import IptrunkImportModel, create_imported_iptrunk
-from gso.api.v1.imports.office_router import OfficeRouterImportModel, create_imported_office_router
-from gso.api.v1.imports.router import RouterImportModel, create_imported_router
-from gso.api.v1.imports.site import SiteImportModel, create_imported_site
-from gso.api.v1.imports.super_pop_switch import SuperPopSwitchImportModel, create_imported_super_pop_switch
 from gso.db.models import PartnerTable
 from gso.products import ProductType
-from gso.services.subscriptions import get_active_subscriptions_by_field_and_value, get_subscriptions
+from gso.products.product_blocks.iptrunk import IptrunkType, PhysicalPortCapacity
+from gso.products.product_blocks.router import RouterRole
+from gso.products.product_blocks.site import SiteTier
+from gso.services.partners import PartnerNotFoundError, get_partner_by_name
+from gso.services.subscriptions import (
+    get_active_router_subscriptions,
+    get_active_subscriptions_by_field_and_value,
+    get_subscriptions,
+)
+from gso.utils.helpers import BaseSiteValidatorModel, LAGMember
+from gso.utils.shared_enums import PortNumber, Vendor
 
 app: typer.Typer = typer.Typer()
 
+
+class SiteImportModel(BaseSiteValidatorModel):
+    """The required input for importing an existing :class:`gso.products.product_types.site`."""
+
+    site_name: str
+    site_city: str
+    site_country: str
+    site_country_code: str
+    site_latitude: float
+    site_longitude: float
+    site_bgp_community_id: int
+    site_internal_id: int
+    site_tier: SiteTier
+    site_ts_address: str
+    partner: str
+
+
+class RouterImportModel(BaseModel):
+    """Required fields for importing an existing :class:`gso.product.product_types.router`."""
+
+    partner: str
+    router_site: str
+    hostname: str
+    ts_port: int
+    router_vendor: Vendor
+    router_role: RouterRole
+    router_lo_ipv4_address: ipaddress.IPv4Address
+    router_lo_ipv6_address: ipaddress.IPv6Address
+    router_lo_iso_address: str
+
+
+class SuperPopSwitchImportModel(BaseModel):
+    """Required fields for importing an existing :class:`gso.product.product_types.super_pop_switch`."""
+
+    partner: str
+    super_pop_switch_site: str
+    hostname: str
+    super_pop_switch_ts_port: PortNumber
+    super_pop_switch_mgmt_ipv4_address: ipaddress.IPv4Address
+
+
+class OfficeRouterImportModel(BaseModel):
+    """Required fields for importing an existing :class:`gso.product.product_types.office_router`."""
+
+    partner: str
+    office_router_site: str
+    office_router_fqdn: str
+    office_router_ts_port: PortNumber
+    office_router_lo_ipv4_address: ipaddress.IPv4Address
+    office_router_lo_ipv6_address: ipaddress.IPv6Address
+
+
+class IptrunkImportModel(BaseModel):
+    """Required fields for importing an existing :class:`gso.products.product_types.iptrunk`."""
+
+    partner: str
+    geant_s_sid: str | None
+    iptrunk_type: IptrunkType
+    iptrunk_description: str
+    iptrunk_speed: PhysicalPortCapacity
+    iptrunk_minimum_links: int
+    iptrunk_isis_metric: int
+    side_a_node_id: str
+    side_a_ae_iface: str
+    side_a_ae_geant_a_sid: str | None
+    side_a_ae_members: list[LAGMember]
+    side_b_node_id: str
+    side_b_ae_iface: str
+    side_b_ae_geant_a_sid: str | None
+    side_b_ae_members: list[LAGMember]
+
+    iptrunk_ipv4_network: ipaddress.IPv4Network
+    iptrunk_ipv6_network: ipaddress.IPv6Network
+
+    @classmethod
+    def _get_active_routers(cls) -> set[str]:
+        return {
+            str(router["subscription_id"]) for router in get_active_router_subscriptions(includes=["subscription_id"])
+        }
+
+    @validator("partner")
+    def check_if_partner_exists(cls, value: str) -> str:
+        """Validate that the partner exists."""
+        try:
+            get_partner_by_name(value)
+        except PartnerNotFoundError as e:
+            msg = f"partner {value} not found"
+            raise ValueError(msg) from e
+
+        return value
+
+    @validator("side_a_node_id", "side_b_node_id")
+    def check_if_router_side_is_available(cls, value: str) -> str:
+        """Both sides of the trunk must exist in :term:`GSO`."""
+        if value not in cls._get_active_routers():
+            msg = f"Router {value} not found"
+            raise ValueError(msg)
+
+        return value
+
+    @validator("side_a_ae_members", "side_b_ae_members")
+    def check_side_uniqueness(cls, value: list[str]) -> list[str]:
+        """:term:`LAG` members must be unique."""
+        if len(value) != len(set(value)):
+            msg = "Items must be unique"
+            raise ValueError(msg)
+
+        return value
+
+    @root_validator
+    def check_members(cls, values: dict[str, Any]) -> dict[str, Any]:
+        """Amount of :term:`LAG` members has to match on side A and B, and meet the minimum requirement."""
+        min_links = values["iptrunk_minimum_links"]
+        side_a_members = values.get("side_a_ae_members", [])
+        side_b_members = values.get("side_b_ae_members", [])
+
+        len_a = len(side_a_members)
+        len_b = len(side_b_members)
+
+        if len_a < min_links:
+            msg = f"Side A members should be at least {min_links} (iptrunk_minimum_links)"
+            raise ValueError(msg)
+
+        if len_a != len_b:
+            msg = "Mismatch between Side A and B members"
+            raise ValueError(msg)
+
+        return values
+
+
 T = TypeVar(
     "T", SiteImportModel, RouterImportModel, IptrunkImportModel, SuperPopSwitchImportModel, OfficeRouterImportModel
 )
@@ -36,10 +171,9 @@ common_filepath_option = typer.Option(
 )
 
 
-def _read_data(filepath: str) -> dict:
+def _read_data(file_path: Path) -> dict:
     """Read data from a JSON or YAML file."""
-    typer.echo(f"Starting import from {filepath}")
-    file_path = Path(filepath)
+    typer.echo(f"Starting import from {file_path!s}")
     file_extension = file_path.suffix.lower()
 
     with file_path.open("r") as f:
@@ -54,34 +188,6 @@ def _read_data(filepath: str) -> dict:
         raise typer.Exit(code=1)
 
 
-def _generic_import_data(
-    filepath: str,
-    import_model: type[T],
-    import_function: callable,  # type: ignore[valid-type]
-    name_key: str,
-) -> None:
-    """Import data from a JSON or YAML file."""
-    successfully_imported_data = []
-    data = _read_data(filepath)
-    for details in data:
-        details["partner"] = "GEANT"
-        typer.echo(f"Importing {name_key}: {details[name_key]}")
-        try:
-            initial_data = import_model(**details)
-            import_function(initial_data)  # type: ignore[misc]
-            successfully_imported_data.append(getattr(initial_data, name_key))
-            typer.echo(
-                f"Successfully imported {name_key}: {getattr(initial_data, name_key)}",
-            )
-        except ValidationError as e:
-            typer.echo(f"Validation error: {e}")
-
-    if successfully_imported_data:
-        typer.echo(f"Successfully imported {name_key}s:")
-        for item in successfully_imported_data:
-            typer.echo(f"- {item}")
-
-
 def _get_router_subscription_id(node_name: str) -> str | None:
     """Get the subscription id for a router by its node name."""
     subscriptions = get_active_subscriptions_by_field_and_value(
@@ -100,63 +206,81 @@ def _import_partners_from_csv(file_path: Path) -> list[dict]:
         return list(csv_reader)
 
 
+def _generic_import_product(
+    file_path: Path, imported_product_type: ProductType, workflow_suffix: str, name_key: str, import_model: type[T]
+) -> None:
+    """Import subscriptions from a JSON or YAML file."""
+    successfully_imported_data = []
+    data = _read_data(file_path)
+    for details in data:
+        details["partner"] = "GEANT"
+        typer.echo(f"Creating imported {name_key}: {details[name_key]}")
+        try:
+            initial_data = import_model(**details)
+            start_process(f"create_imported_{workflow_suffix}", [initial_data.dict()])
+            successfully_imported_data.append(getattr(initial_data, name_key))
+            typer.echo(
+                f"Successfully created {name_key}: {getattr(initial_data, name_key)}",
+            )
+        except ValidationError as e:
+            typer.echo(f"Validation error: {e}")
+
+    #  Migrate new products from imported to "full" counterpart.
+    imported_products = get_subscriptions(
+        [imported_product_type], lifecycles=[SubscriptionLifecycle.ACTIVE], includes=["subscription_id"]
+    )
+    for subscription_id in imported_products:
+        typer.echo(f"Importing {subscription_id}")
+        start_process(f"import_{workflow_suffix}", [subscription_id])
+
+    if successfully_imported_data:
+        typer.echo(f"Successfully created imported {name_key}s:")
+        for item in successfully_imported_data:
+            typer.echo(f"- {item}")
+        typer.echo(f"Please validate no more imported {workflow_suffix} products exist anymore in the database.")
+
+
 @app.command()
 def import_sites(filepath: str = common_filepath_option) -> None:
     """Import sites into GSO."""
-    # Use the import_data function to handle common import logic
-    _generic_import_data(filepath, SiteImportModel, create_imported_site, "site_name")
-
-    site_ids = get_subscriptions(
-        [ProductType.IMPORTED_SITE], lifecycles=[SubscriptionLifecycle.ACTIVE], includes=["subscription_id"]
-    )
-    for subscription_id in site_ids:
-        start_process("import_site", [subscription_id])
+    _generic_import_product(Path(filepath), ProductType.IMPORTED_SITE, "site", "site_name", SiteImportModel)
 
 
 @app.command()
 def import_routers(filepath: str = common_filepath_option) -> None:
     """Import routers into GSO."""
-    # Use the import_data function to handle common import logic
-    _generic_import_data(filepath, RouterImportModel, create_imported_router, "hostname")
-
-    router_ids = get_subscriptions(
-        [ProductType.IMPORTED_ROUTER], lifecycles=[SubscriptionLifecycle.ACTIVE], includes=["subscription_id"]
-    )
-    for subscription_id in router_ids:
-        start_process("import_router", [subscription_id])
+    _generic_import_product(Path(filepath), ProductType.IMPORTED_ROUTER, "router", "hostname", RouterImportModel)
 
 
 @app.command()
 def import_super_pop_switches(filepath: str = common_filepath_option) -> None:
     """Import Super PoP Switches into GSO."""
-    # Use the import_data function to handle common import logic
-    _generic_import_data(filepath, SuperPopSwitchImportModel, create_imported_super_pop_switch, "hostname")
-
-    super_pop_switch_ids = get_subscriptions(
-        [ProductType.IMPORTED_SUPER_POP_SWITCH], lifecycles=[SubscriptionLifecycle.ACTIVE], includes=["subscription_id"]
+    _generic_import_product(
+        Path(filepath),
+        ProductType.IMPORTED_SUPER_POP_SWITCH,
+        "super_pop_switch",
+        "hostname",
+        SuperPopSwitchImportModel,
     )
-    for subscription_id in super_pop_switch_ids:
-        start_process("import_super_pop_switch", [subscription_id])
 
 
 @app.command()
 def import_office_routers(filepath: str = common_filepath_option) -> None:
     """Import office routers into GSO."""
-    # Use the import_data function to handle common import logic
-    _generic_import_data(filepath, OfficeRouterImportModel, create_imported_office_router, "office_router_fqdn")
-
-    office_router_ids = get_subscriptions(
-        [ProductType.IMPORTED_OFFICE_ROUTER], lifecycles=[SubscriptionLifecycle.ACTIVE], includes=["subscription_id"]
+    _generic_import_product(
+        Path(filepath),
+        ProductType.IMPORTED_OFFICE_ROUTER,
+        "office_router",
+        "office_router_fqdn",
+        OfficeRouterImportModel,
     )
-    for subscription_id in office_router_ids:
-        start_process("import_office_router", [subscription_id])
 
 
 @app.command()
 def import_iptrunks(filepath: str = common_filepath_option) -> None:
     """Import IP trunks into GSO."""
     successfully_imported_data = []
-    data = _read_data(filepath)
+    data = _read_data(Path(filepath))
     for trunk in data:
         ipv4_network_a = ipaddress.IPv4Network(trunk["config"]["nodeA"]["ipv4_address"], strict=False)
         ipv4_network_b = ipaddress.IPv4Network(trunk["config"]["nodeB"]["ipv4_address"], strict=False)
@@ -201,7 +325,7 @@ def import_iptrunks(filepath: str = common_filepath_option) -> None:
                 iptrunk_ipv4_network=iptrunk_ipv4_network,
                 iptrunk_ipv6_network=iptrunk_ipv6_network,
             )
-            create_imported_iptrunk(initial_data)
+            start_process("create_imported_iptrunk", [initial_data.dict()])
             successfully_imported_data.append(trunk["id"])
             typer.echo(f"Successfully imported IP Trunk: {trunk['id']}")
         except ValidationError as e:
@@ -211,6 +335,7 @@ def import_iptrunks(filepath: str = common_filepath_option) -> None:
         [ProductType.IMPORTED_IP_TRUNK], lifecycles=[SubscriptionLifecycle.ACTIVE], includes=["subscription_id"]
     )
     for subscription_id in trunk_ids:
+        typer.echo(f"Migrating iptrunk {subscription_id}")
         start_process("import_iptrunk", [subscription_id])
 
     if successfully_imported_data:
diff --git a/requirements.txt b/requirements.txt
index 73bb46ff..eea6234f 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -5,6 +5,7 @@ pycountry==22.3.5
 pynetbox==7.2.0
 celery-redbeat==2.1.1
 celery==5.3.4
+typer==0.7.0
 
 # Test and linting dependencies
 celery-stubs==0.1.3
diff --git a/setup.py b/setup.py
index f60d9aaf..d22b3404 100644
--- a/setup.py
+++ b/setup.py
@@ -16,6 +16,7 @@ setup(
         "pynetbox==7.2.0",
         "celery-redbeat==2.1.1",
         "celery==5.3.4",
+        "typer==0.7.0",
     ],
     include_package_data=True,
 )
diff --git a/test/api/test_imports.py b/test/api/test_imports.py
deleted file mode 100644
index ec171093..00000000
--- a/test/api/test_imports.py
+++ /dev/null
@@ -1,426 +0,0 @@
-from unittest.mock import patch
-from uuid import uuid4
-
-import pytest
-from orchestrator.db import SubscriptionTable
-from orchestrator.services import subscriptions
-
-from gso.products.product_blocks.iptrunk import IptrunkType, PhysicalPortCapacity
-from gso.products.product_blocks.router import RouterRole
-from gso.products.product_blocks.site import SiteTier
-from gso.utils.helpers import iso_from_ipv4
-from gso.utils.shared_enums import Vendor
-
-SITE_IMPORT_ENDPOINT = "/api/v1/imports/sites"
-ROUTER_IMPORT_ENDPOINT = "/api/v1/imports/routers"
-IPTRUNK_IMPORT_API_URL = "/api/v1/imports/iptrunks"
-SUPER_POP_SWITCH_IMPORT_API_URL = "/api/v1/imports/super-pop-switches"
-OFFICE_ROUTER_IMPORT_API_URL = "/api/v1/imports/office-routers"
-
-
-@pytest.fixture()
-def iptrunk_data(nokia_router_subscription_factory, faker):
-    router_side_a = nokia_router_subscription_factory()
-    router_side_b = nokia_router_subscription_factory()
-    return {
-        "partner": "GEANT",
-        "geant_s_sid": faker.geant_sid(),
-        "iptrunk_type": IptrunkType.DARK_FIBER,
-        "iptrunk_description": faker.sentence(),
-        "iptrunk_speed": PhysicalPortCapacity.HUNDRED_GIGABIT_PER_SECOND,
-        "iptrunk_minimum_links": 5,
-        "iptrunk_isis_metric": 500,
-        "side_a_node_id": router_side_a,
-        "side_a_ae_iface": faker.network_interface(),
-        "side_a_ae_geant_a_sid": faker.geant_sid(),
-        "side_a_ae_members": [
-            {
-                "interface_name": faker.network_interface(),
-                "interface_description": faker.sentence(),
-            }
-            for _ in range(5)
-        ],
-        "side_b_node_id": router_side_b,
-        "side_b_ae_iface": faker.network_interface(),
-        "side_b_ae_geant_a_sid": faker.geant_sid(),
-        "side_b_ae_members": [
-            {
-                "interface_name": faker.network_interface(),
-                "interface_description": faker.sentence(),
-            }
-            for _ in range(5)
-        ],
-        "iptrunk_ipv4_network": str(faker.ipv4(network=True)),
-        "iptrunk_ipv6_network": str(faker.ipv6(network=True)),
-    }
-
-
-@pytest.fixture()
-def mock_routers(iptrunk_data):
-    with patch("gso.services.subscriptions.get_active_router_subscriptions") as mock_get_active_router_subscriptions:
-
-        def _active_router_subscriptions(*args, **kwargs):
-            if kwargs["includes"] == ["subscription_id", "description"]:
-                return [
-                    {
-                        "subscription_id": iptrunk_data["side_a_node_id"],
-                        "description": "iptrunk_sideA_node_id description",
-                    },
-                    {
-                        "subscription_id": iptrunk_data["side_b_node_id"],
-                        "description": "iptrunk_sideB_node_id description",
-                    },
-                    {
-                        "subscription_id": str(uuid4()),
-                        "description": "random description",
-                    },
-                ]
-            return [
-                {"subscription_id": iptrunk_data["side_a_node_id"]},
-                {"subscription_id": iptrunk_data["side_b_node_id"]},
-                {"subscription_id": str(uuid4())},
-            ]
-
-        mock_get_active_router_subscriptions.side_effect = _active_router_subscriptions
-        yield mock_get_active_router_subscriptions
-
-
-@patch("gso.api.v1.imports.iptrunk._start_process")
-def test_create_imported_iptrunk_successful_with_mocked_process(
-    mock_start_process, test_client, mock_routers, iptrunk_data
-):
-    mock_start_process.return_value = "123e4567-e89b-12d3-a456-426655440000"
-    response = test_client.post(IPTRUNK_IMPORT_API_URL, json=iptrunk_data)
-
-    assert response.status_code == 201
-    assert response.json()["pid"] == "123e4567-e89b-12d3-a456-426655440000"
-
-
-@pytest.fixture()
-def site_data(faker):
-    return {
-        "site_name": faker.site_name(),
-        "site_city": faker.city(),
-        "site_country": faker.country(),
-        "site_country_code": faker.country_code(),
-        "site_latitude": float(faker.latitude()),
-        "site_longitude": float(faker.longitude()),
-        "site_bgp_community_id": faker.pyint(),
-        "site_internal_id": faker.pyint(),
-        "site_tier": SiteTier.TIER1,
-        "site_ts_address": faker.ipv4(),
-        "partner": "GEANT",
-    }
-
-
-@pytest.fixture()
-def router_data(faker, site_data):
-    mock_ipv4 = faker.ipv4()
-    return {
-        "hostname": "127.0.0.1",
-        "router_role": RouterRole.PE,
-        "router_vendor": Vendor.JUNIPER,
-        "router_site": site_data["site_name"],
-        "ts_port": 1234,
-        "partner": "GEANT",
-        "router_lo_ipv4_address": mock_ipv4,
-        "router_lo_ipv6_address": faker.ipv6(),
-        "router_lo_iso_address": iso_from_ipv4(mock_ipv4),
-    }
-
-
-@pytest.fixture()
-def super_pop_switch_data(faker, site_data):
-    mock_ipv4 = faker.ipv4()
-    return {
-        "hostname": "127.0.0.1",
-        "super_pop_switch_site": site_data["site_name"],
-        "super_pop_switch_ts_port": 1234,
-        "partner": "GEANT",
-        "super_pop_switch_mgmt_ipv4_address": mock_ipv4,
-    }
-
-
-@pytest.fixture()
-def office_router_data(faker, site_data):
-    return {
-        "office_router_fqdn": "127.0.0.1",
-        "office_router_site": site_data["site_name"],
-        "office_router_ts_port": 1234,
-        "partner": "GEANT",
-        "office_router_lo_ipv4_address": faker.ipv4(),
-        "office_router_lo_ipv6_address": faker.ipv6(),
-    }
-
-
-def test_create_imported_site_endpoint(test_client, site_data):
-    assert SubscriptionTable.query.all() == []
-    # Post data to the endpoint
-    response = test_client.post(SITE_IMPORT_ENDPOINT, json=site_data)
-    assert response.status_code == 201
-    assert "detail" in response.json()
-    assert "pid" in response.json()
-    subscription = subscriptions.retrieve_subscription_by_subscription_instance_value(
-        resource_type="site_name",
-        value=site_data["site_name"],
-    )
-    assert subscription is not None
-
-
-def test_create_imported_site_endpoint_with_existing_site(test_client, site_data):
-    response = test_client.post(SITE_IMPORT_ENDPOINT, json=site_data)
-    assert SubscriptionTable.query.count() == 1
-    assert response.status_code == 201
-
-    response = test_client.post(SITE_IMPORT_ENDPOINT, json=site_data)
-    assert response.status_code == 422
-    assert SubscriptionTable.query.count() == 1
-
-
-def test_create_imported_site_endpoint_with_invalid_data(test_client, site_data):
-    # invalid data, missing site_latitude and invalid site_longitude
-    site_data.pop("site_latitude")
-    site_data["site_longitude"] = "invalid"
-    assert SubscriptionTable.query.count() == 0
-    response = test_client.post(SITE_IMPORT_ENDPOINT, json=site_data)
-    assert response.status_code == 422
-    assert SubscriptionTable.query.count() == 0
-    response = response.json()
-    assert response["detail"][0]["loc"] == ["body", "site_latitude"]
-    assert response["detail"][0]["msg"] == "field required"
-    assert response["detail"][1]["loc"] == ["body", "site_longitude"]
-    assert response["detail"][1]["msg"] == "value is not a valid float"
-
-
-def test_create_imported_router_endpoint(test_client, site_data, router_data):
-    # Create a site first
-    response = test_client.post(SITE_IMPORT_ENDPOINT, json=site_data)
-    assert response.status_code == 201
-    assert SubscriptionTable.query.count() == 1
-
-    response = test_client.post(ROUTER_IMPORT_ENDPOINT, json=router_data)
-    assert response.status_code == 201
-    assert SubscriptionTable.query.count() == 2
-
-
-def test_create_imported_router_endpoint_with_invalid_data(test_client, site_data, router_data):
-    response = test_client.post(SITE_IMPORT_ENDPOINT, json=site_data)
-    assert response.status_code == 201
-    assert SubscriptionTable.query.count() == 1
-
-    # invalid data, missing hostname and invalid router_lo_ipv6_address
-    router_data.pop("hostname")
-    router_data["router_lo_ipv6_address"] = "invalid"
-    response = test_client.post(ROUTER_IMPORT_ENDPOINT, json=router_data)
-    assert response.status_code == 422
-    assert SubscriptionTable.query.count() == 1
-    response = response.json()
-    assert response["detail"][0]["loc"] == ["body", "hostname"]
-    assert response["detail"][0]["msg"] == "field required"
-    assert response["detail"][1]["loc"] == ["body", "router_lo_ipv6_address"]
-    assert response["detail"][1]["msg"] == "value is not a valid IPv6 address"
-
-
-def test_create_imported_iptrunk_successful_with_real_process(test_client, mock_routers, iptrunk_data):
-    response = test_client.post(IPTRUNK_IMPORT_API_URL, json=iptrunk_data)
-    assert response.status_code == 201
-
-    response = response.json()
-    assert "detail" in response
-    assert "pid" in response
-
-    subscription = subscriptions.retrieve_subscription_by_subscription_instance_value(
-        resource_type="geant_s_sid",
-        value=iptrunk_data["geant_s_sid"],
-    )
-    assert subscription is not None
-
-
-@patch("gso.api.helpers._start_process")
-def test_create_imported_iptrunk_invalid_partner(mock_start_process, test_client, mock_routers, iptrunk_data):
-    iptrunk_data["partner"] = "not_existing_partner"
-    mock_start_process.return_value = "123e4567-e89b-12d3-a456-426655440000"
-    response = test_client.post(IPTRUNK_IMPORT_API_URL, json=iptrunk_data)
-
-    assert response.status_code == 422
-    assert response.json() == {
-        "detail": [
-            {
-                "loc": ["body", "partner"],
-                "msg": "partner not_existing_partner not found",
-                "type": "value_error",
-            },
-        ],
-    }
-
-
-@patch("gso.api.helpers._start_process")
-def test_create_imported_iptrunk_invalid_router_id_side_a_and_b(mock_start_process, test_client, iptrunk_data):
-    iptrunk_data["side_a_node_id"] = "NOT FOUND"
-    iptrunk_data["side_b_node_id"] = "NOT FOUND"
-
-    mock_start_process.return_value = "123e4567-e89b-12d3-a456-426655440000"
-    response = test_client.post(IPTRUNK_IMPORT_API_URL, json=iptrunk_data)
-
-    assert response.status_code == 422
-    assert response.json() == {
-        "detail": [
-            {
-                "loc": ["body", "side_a_node_id"],
-                "msg": f"Router {iptrunk_data['side_a_node_id']} not found",
-                "type": "value_error",
-            },
-            {
-                "loc": ["body", "side_b_node_id"],
-                "msg": f"Router {iptrunk_data['side_b_node_id']} not found",
-                "type": "value_error",
-            },
-        ],
-    }
-
-
-@patch("gso.api.helpers._start_process")
-def test_create_imported_iptrunk_non_unique_members_side_a(
-    mock_start_process, test_client, mock_routers, iptrunk_data, faker
-):
-    mock_start_process.return_value = "123e4567-e89b-12d3-a456-426655440000"
-
-    repeat_interface_a = {
-        "interface_name": faker.network_interface(),
-        "interface_description": faker.sentence(),
-    }
-    repeat_interface_b = {
-        "interface_name": faker.network_interface(),
-        "interface_description": faker.sentence(),
-    }
-    iptrunk_data["side_a_ae_members"] = [repeat_interface_a for _ in range(5)]
-    iptrunk_data["side_b_ae_members"] = [repeat_interface_b for _ in range(5)]
-
-    response = test_client.post(IPTRUNK_IMPORT_API_URL, json=iptrunk_data)
-
-    assert response.status_code == 422
-    assert response.json() == {
-        "detail": [
-            {
-                "loc": ["body", "side_a_ae_members"],
-                "msg": "Items must be unique",
-                "type": "value_error",
-            },
-            {
-                "loc": ["body", "side_b_ae_members"],
-                "msg": "Items must be unique",
-                "type": "value_error",
-            },
-            {
-                "loc": ["body", "__root__"],
-                "msg": "Side A members should be at least 5 (iptrunk_minimum_links)",
-                "type": "value_error",
-            },
-        ],
-    }
-
-
-@patch("gso.api.helpers._start_process")
-def test_create_imported_iptrunk_fails_on_side_a_member_count_mismatch(
-    mock_start_process,
-    test_client,
-    mock_routers,
-    iptrunk_data,
-):
-    mock_start_process.return_value = "123e4567-e89b-12d3-a456-426655440000"
-
-    iptrunk_data["side_a_ae_members"].remove(iptrunk_data["side_a_ae_members"][0])
-
-    response = test_client.post(IPTRUNK_IMPORT_API_URL, json=iptrunk_data)
-
-    assert response.status_code == 422
-    assert response.json() == {
-        "detail": [
-            {
-                "loc": ["body", "__root__"],
-                "msg": "Side A members should be at least 5 (iptrunk_minimum_links)",
-                "type": "value_error",
-            },
-        ],
-    }
-
-
-@patch("gso.api.helpers._start_process")
-def test_create_imported_iptrunk_fails_on_side_a_and_b_members_mismatch(
-    mock_start_process,
-    test_client,
-    iptrunk_data,
-    mock_routers,
-):
-    mock_start_process.return_value = "123e4567-e89b-12d3-a456-426655440000"
-
-    iptrunk_data["side_b_ae_members"].remove(iptrunk_data["side_b_ae_members"][0])
-
-    response = test_client.post(IPTRUNK_IMPORT_API_URL, json=iptrunk_data)
-
-    assert response.status_code == 422
-    assert response.json() == {
-        "detail": [
-            {
-                "loc": ["body", "__root__"],
-                "msg": "Mismatch between Side A and B members",
-                "type": "value_error",
-            },
-        ],
-    }
-
-
-def test_create_imported_super_pop_switch_endpoint(test_client, site_data, super_pop_switch_data):
-    response = test_client.post(SITE_IMPORT_ENDPOINT, json=site_data)
-    assert response.status_code == 201
-    assert SubscriptionTable.query.count() == 1
-
-    response = test_client.post(SUPER_POP_SWITCH_IMPORT_API_URL, json=super_pop_switch_data)
-    assert response.status_code == 201
-    assert SubscriptionTable.query.count() == 2
-
-
-def test_create_imported_super_pop_switch_endpoint_with_invalid_data(test_client, site_data, super_pop_switch_data):
-    response = test_client.post(SITE_IMPORT_ENDPOINT, json=site_data)
-    assert response.status_code == 201
-    assert SubscriptionTable.query.count() == 1
-
-    # invalid data, missing hostname and invalid mgmt_ipv4_address
-    super_pop_switch_data.pop("hostname")
-    super_pop_switch_data["super_pop_switch_mgmt_ipv4_address"] = "invalid"
-    response = test_client.post(SUPER_POP_SWITCH_IMPORT_API_URL, json=super_pop_switch_data)
-    assert response.status_code == 422
-    assert SubscriptionTable.query.count() == 1
-    response = response.json()
-    assert response["detail"][0]["loc"] == ["body", "hostname"]
-    assert response["detail"][0]["msg"] == "field required"
-    assert response["detail"][1]["loc"] == ["body", "super_pop_switch_mgmt_ipv4_address"]
-    assert response["detail"][1]["msg"] == "value is not a valid IPv4 address"
-
-
-def test_create_imported_office_router_endpoint(test_client, site_data, office_router_data):
-    response = test_client.post(SITE_IMPORT_ENDPOINT, json=site_data)
-    assert response.status_code == 201
-    assert SubscriptionTable.query.count() == 1
-
-    response = test_client.post(OFFICE_ROUTER_IMPORT_API_URL, json=office_router_data)
-    assert response.status_code == 201
-    assert SubscriptionTable.query.count() == 2
-
-
-def test_create_imported_office_router_endpoint_with_invalid_data(test_client, site_data, office_router_data):
-    response = test_client.post(SITE_IMPORT_ENDPOINT, json=site_data)
-    assert response.status_code == 201
-    assert SubscriptionTable.query.count() == 1
-
-    # invalid data, missing FQDN and invalid lo_ipv6_address
-    office_router_data.pop("office_router_fqdn")
-    office_router_data["office_router_lo_ipv6_address"] = "invalid"
-    response = test_client.post(OFFICE_ROUTER_IMPORT_API_URL, json=office_router_data)
-    assert response.status_code == 422
-    assert SubscriptionTable.query.count() == 1
-    response = response.json()
-    assert response["detail"][0]["loc"] == ["body", "office_router_fqdn"]
-    assert response["detail"][0]["msg"] == "field required"
-    assert response["detail"][1]["loc"] == ["body", "office_router_lo_ipv6_address"]
-    assert response["detail"][1]["msg"] == "value is not a valid IPv6 address"
diff --git a/test/cli/__init__.py b/test/cli/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/test/cli/conftest.py b/test/cli/conftest.py
new file mode 100644
index 00000000..e002fa13
--- /dev/null
+++ b/test/cli/conftest.py
@@ -0,0 +1,8 @@
+from test.fixtures import (  # noqa: F401
+    iptrunk_side_subscription_factory,
+    iptrunk_subscription_factory,
+    nokia_router_subscription_factory,
+    office_router_subscription_factory,
+    site_subscription_factory,
+    super_pop_switch_subscription_factory,
+)
diff --git a/test/cli/test_imports.py b/test/cli/test_imports.py
new file mode 100644
index 00000000..f8e5324b
--- /dev/null
+++ b/test/cli/test_imports.py
@@ -0,0 +1,350 @@
+import json
+from pathlib import Path
+
+import pytest
+from orchestrator.db import SubscriptionTable
+from orchestrator.services.subscriptions import retrieve_subscription_by_subscription_instance_value
+
+from gso.cli.imports import (
+    import_iptrunks,
+    import_office_routers,
+    import_routers,
+    import_sites,
+    import_super_pop_switches,
+)
+from gso.products import ProductType, Router, Site
+from gso.products.product_blocks.iptrunk import IptrunkType, PhysicalPortCapacity
+from gso.products.product_blocks.router import RouterRole
+from gso.products.product_blocks.site import SiteTier
+from gso.services.subscriptions import (
+    get_active_iptrunk_subscriptions,
+    get_active_router_subscriptions,
+    get_subscriptions,
+)
+from gso.utils.helpers import iso_from_ipv4
+from gso.utils.shared_enums import Vendor
+
+
+##############
+#  FIXTURES  #
+##############
+@pytest.fixture()
+def temp_file(tmp_path) -> Path:
+    return tmp_path / "data.json"
+
+
+@pytest.fixture()
+def iptrunk_data(temp_file, nokia_router_subscription_factory, faker) -> (Path, dict):
+    def _iptrunk_data(
+        *,
+        ipv4_network=None,
+        ipv6_network=None,
+        min_links=None,
+        isis_metric=None,
+        side_a_node=None,
+        side_b_node=None,
+        side_a_members=None,
+        side_b_members=None,
+        side_a_ae_name=None,
+        side_b_ae_name=None,
+    ):
+        router_side_a = nokia_router_subscription_factory()
+        router_side_b = nokia_router_subscription_factory()
+        ipv4_network = ipv4_network or str(faker.ipv4_network(max_subnet=31))
+        ipv6_network = ipv6_network or str(faker.ipv6_network(max_subnet=126))
+
+        iptrunk_data = {
+            "id": faker.geant_sid(),
+            "config": {
+                "common": {
+                    "link_speed": PhysicalPortCapacity.HUNDRED_GIGABIT_PER_SECOND,
+                    "minimum_links": min_links or 3,
+                    "isis_metric": isis_metric or 500,
+                    "type": IptrunkType.DARK_FIBER,
+                },
+                "nodeA": {
+                    "name": side_a_node or Router.from_subscription(router_side_a).router.router_fqdn,
+                    "ae_name": side_a_ae_name or faker.network_interface(),
+                    "port_sid": faker.geant_sid(),
+                    "members": side_a_members
+                    or [
+                        {
+                            "interface_name": faker.network_interface(),
+                            "interface_description": faker.sentence(),
+                        }
+                        for _ in range(5)
+                    ],
+                    "ipv4_address": ipv4_network,
+                    "ipv6_address": ipv6_network,
+                },
+                "nodeB": {
+                    "name": side_b_node or Router.from_subscription(router_side_b).router.router_fqdn,
+                    "ae_name": side_b_ae_name or faker.network_interface(),
+                    "port_sid": faker.geant_sid(),
+                    "members": side_b_members
+                    or [
+                        {
+                            "interface_name": faker.network_interface(),
+                            "interface_description": faker.sentence(),
+                        }
+                        for _ in range(5)
+                    ],
+                    "ipv4_address": ipv4_network,
+                    "ipv6_address": ipv6_network,
+                },
+            },
+        }
+
+        temp_file.write_text(json.dumps([iptrunk_data]))
+        return {"path": str(temp_file), "data": iptrunk_data}
+
+    return _iptrunk_data
+
+
+@pytest.fixture()
+def site_data(faker, temp_file):
+    def _site_data(**kwargs):
+        site_data = {
+            "site_name": faker.site_name(),
+            "site_city": faker.city(),
+            "site_country": faker.country(),
+            "site_country_code": faker.country_code(),
+            "site_latitude": float(faker.latitude()),
+            "site_longitude": float(faker.longitude()),
+            "site_bgp_community_id": faker.pyint(),
+            "site_internal_id": faker.pyint(),
+            "site_tier": SiteTier.TIER1,
+            "site_ts_address": faker.ipv4(),
+        }
+        site_data.update(**kwargs)
+
+        temp_file.write_text(json.dumps([site_data]))
+        return {"path": str(temp_file), "data": site_data}
+
+    return _site_data
+
+
+@pytest.fixture()
+def router_data(temp_file, faker, site_subscription_factory):
+    def _router_data(**kwargs):
+        mock_ipv4 = faker.ipv4()
+        router_data = {
+            "router_site": Site.from_subscription(site_subscription_factory()).site.site_name,
+            "hostname": str(faker.ipv4()),
+            "ts_port": faker.port_number(),
+            "router_role": RouterRole.PE,
+            "router_vendor": Vendor.JUNIPER,
+            "router_lo_ipv4_address": mock_ipv4,
+            "router_lo_ipv6_address": str(faker.ipv6()),
+            "router_lo_iso_address": iso_from_ipv4(mock_ipv4),
+        }
+        router_data.update(**kwargs)
+
+        temp_file.write_text(json.dumps([router_data]))
+        return {"path": str(temp_file), "data": router_data}
+
+    return _router_data
+
+
+@pytest.fixture()
+def super_pop_switch_data(temp_file, faker, site_subscription_factory):
+    def _super_pop_switch_data(**kwargs):
+        super_pop_switch_data = {
+            "hostname": str(faker.ipv4()),
+            "super_pop_switch_site": Site.from_subscription(site_subscription_factory()).site.site_name,
+            "super_pop_switch_ts_port": faker.port_number(),
+            "super_pop_switch_mgmt_ipv4_address": str(faker.ipv4()),
+        }
+        super_pop_switch_data.update(**kwargs)
+
+        temp_file.write_text(json.dumps([super_pop_switch_data]))
+        return {"path": str(temp_file), "data": super_pop_switch_data}
+
+    return _super_pop_switch_data
+
+
+@pytest.fixture()
+def office_router_data(temp_file, faker, site_subscription_factory):
+    def _office_router_data(**kwargs):
+        office_router_data = {
+            "office_router_fqdn": faker.domain_name(levels=4),
+            "office_router_site": Site.from_subscription(site_subscription_factory()).site.site_name,
+            "office_router_ts_port": faker.port_number(),
+            "office_router_lo_ipv4_address": str(faker.ipv4()),
+            "office_router_lo_ipv6_address": str(faker.ipv6()),
+        }
+        office_router_data.update(**kwargs)
+
+        temp_file.write_text(json.dumps([office_router_data]))
+        return {"path": str(temp_file), "data": office_router_data}
+
+    return _office_router_data
+
+
+###########
+#  TESTS  #
+###########
+
+
+def test_import_iptrunk_success(iptrunk_data):
+    assert len(get_active_iptrunk_subscriptions()) == 0
+
+    import_iptrunks(iptrunk_data()["path"])
+
+    assert len(get_subscriptions([ProductType.IMPORTED_IP_TRUNK])) == 0
+    assert len(get_active_iptrunk_subscriptions()) == 1
+
+
+def test_import_site_success(site_data):
+    assert SubscriptionTable.query.all() == []
+    mock_site_data = site_data()
+
+    import_sites(mock_site_data["path"])
+
+    assert len(get_subscriptions([ProductType.IMPORTED_SITE])) == 0
+    subscription = retrieve_subscription_by_subscription_instance_value(
+        resource_type="site_name", value=mock_site_data["data"]["site_name"]
+    )
+    assert subscription is not None
+
+
+def test_import_site_twice(site_data, capfd):
+    path_location = site_data()["path"]
+    import_sites(path_location)
+    #  Second identical import should print ValidationError to stdout
+    import_sites(path_location)
+
+    out, _ = capfd.readouterr()
+    assert (
+        """Validation error: 4 validation errors for SiteImportModel
+site_bgp_community_id
+  site_bgp_community_id must be unique (type=value_error)
+site_internal_id
+  site_internal_id must be unique (type=value_error)
+site_ts_address
+  site_ts_address must be unique (type=value_error)
+site_name
+  site_name must be unique (type=value_error)"""
+        in out
+    )
+    assert SubscriptionTable.query.count() == 1
+
+
+def test_import_site_with_invalid_data(site_data, capfd):
+    # invalid data, missing site_latitude and invalid site_longitude
+    incorrect_site_data = site_data(site_latitude=None, site_longitude="broken")
+
+    assert SubscriptionTable.query.count() == 0
+    import_sites(incorrect_site_data["path"])
+
+    out, _ = capfd.readouterr()
+    assert (
+        """Validation error: 2 validation errors for SiteImportModel
+site_latitude
+  none is not an allowed value (type=type_error.none.not_allowed)
+site_longitude
+  value is not a valid float (type=type_error.float)"""
+        in out
+    )
+    assert SubscriptionTable.query.count() == 0
+
+
+def test_import_router_success(site_subscription_factory, router_data):
+    assert SubscriptionTable.query.count() == 0
+    import_routers(router_data()["path"])
+    assert len(get_active_router_subscriptions()) == 1
+
+
+def test_import_router_with_invalid_data(router_data, capfd):
+    broken_data = router_data(hostname="", router_lo_ipv6_address="Not an IP address")
+    import_routers(broken_data["path"])
+    #  Only a Site has been added, no Router
+    assert SubscriptionTable.query.count() == 1
+    out, _ = capfd.readouterr()
+    #  The extra space at the end of the next line is required, and not dangling by accident.
+    assert "Validation error: 1 validation error for RouterImportModel" in out
+    assert (
+        """router_lo_ipv6_address
+  value is not a valid IPv6 address (type=value_error.ipv6address)"""
+        in out
+    )
+
+
+def test_import_iptrunk_successful(iptrunk_data):
+    assert SubscriptionTable.query.count() == 0
+    import_iptrunks(iptrunk_data()["path"])
+    assert SubscriptionTable.query.count() == 5
+    assert len(get_active_iptrunk_subscriptions()) == 1
+
+
+def test_import_iptrunk_invalid_router_id_side_a_and_b(iptrunk_data, capfd):
+    broken_data = iptrunk_data(side_a_node="Doesn't exist", side_b_node="Also doesn't exist")
+    import_iptrunks(broken_data["path"])
+    out, _ = capfd.readouterr()
+    assert SubscriptionTable.query.count() == 4
+    assert len(get_active_iptrunk_subscriptions()) == 0
+    assert (
+        """Validation error: 2 validation errors for IptrunkImportModel
+side_a_node_id
+  Router  not found (type=value_error)
+side_b_node_id
+  Router  not found (type=value_error)"""
+        in out
+    )
+
+
+def test_import_iptrunk_non_unique_members_side_a_and_b(iptrunk_data, faker, capfd):
+    duplicate_interface = {"interface_name": faker.network_interface(), "interface_description": faker.sentence()}
+    side_a_members = [duplicate_interface for _ in range(5)]
+    side_b_members = [duplicate_interface for _ in range(5)]
+    broken_data = iptrunk_data(side_a_members=side_a_members, side_b_members=side_b_members)
+    import_iptrunks(broken_data["path"])
+    out, _ = capfd.readouterr()
+
+    assert SubscriptionTable.query.count() == 4
+    assert len(get_active_iptrunk_subscriptions()) == 0
+    assert (
+        """Validation error: 3 validation errors for IptrunkImportModel
+side_a_ae_members
+  Items must be unique (type=value_error)
+side_b_ae_members
+  Items must be unique (type=value_error)"""
+        in out
+    )
+
+
+def test_import_iptrunk_side_a_member_count_mismatch(iptrunk_data, faker, capfd):
+    side_a_members = [
+        {"interface_name": faker.network_interface(), "interface_description": faker.sentence()} for _ in range(5)
+    ]
+    side_b_members = [
+        {"interface_name": faker.network_interface(), "interface_description": faker.sentence()} for _ in range(6)
+    ]
+    broken_data = iptrunk_data(side_a_members=side_a_members, side_b_members=side_b_members)
+    import_iptrunks(broken_data["path"])
+    out, _ = capfd.readouterr()
+
+    assert SubscriptionTable.query.count() == 4
+    assert len(get_active_iptrunk_subscriptions()) == 0
+    assert (
+        """Validation error: 1 validation error for IptrunkImportModel
+__root__
+  Mismatch between Side A and B members (type=value_error)"""
+        in out
+    )
+
+
+def test_import_office_router_success(office_router_data):
+    assert SubscriptionTable.query.count() == 0
+    import_office_routers(office_router_data()["path"])
+    assert SubscriptionTable.query.count() == 2
+    assert len(get_subscriptions([ProductType.IMPORTED_OFFICE_ROUTER])) == 0
+    assert len(get_subscriptions([ProductType.OFFICE_ROUTER])) == 1
+
+
+def test_import_super_pop_switch_success(super_pop_switch_data):
+    assert SubscriptionTable.query.count() == 0
+    import_super_pop_switches(super_pop_switch_data()["path"])
+    assert SubscriptionTable.query.count() == 2
+    assert len(get_subscriptions([ProductType.IMPORTED_SUPER_POP_SWITCH])) == 0
+    assert len(get_subscriptions([ProductType.SUPER_POP_SWITCH])) == 1
-- 
GitLab