fix: commit verified semantic repair changes
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
# [DEF:backend.src.api.routes.__tests__.test_git_api:Module]
|
# [DEF:TestGitApi:Module]
|
||||||
# @RELATION: VERIFIES -> src.api.routes.git
|
# @COMPLEXITY: 3
|
||||||
|
# @RELATION: VERIFIES ->[src.api.routes.git]
|
||||||
# @PURPOSE: API tests for Git configurations and repository operations.
|
# @PURPOSE: API tests for Git configurations and repository operations.
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -9,32 +10,52 @@ from fastapi import HTTPException
|
|||||||
from src.api.routes import git as git_routes
|
from src.api.routes import git as git_routes
|
||||||
from src.models.git import GitServerConfig, GitProvider, GitStatus, GitRepository
|
from src.models.git import GitServerConfig, GitProvider, GitStatus, GitRepository
|
||||||
|
|
||||||
|
|
||||||
class DbMock:
|
class DbMock:
|
||||||
def __init__(self, data=None):
|
def __init__(self, data=None):
|
||||||
self._data = data or []
|
self._data = data or []
|
||||||
self._deleted = []
|
self._deleted = []
|
||||||
self._added = []
|
self._added = []
|
||||||
|
self._filtered = None
|
||||||
|
|
||||||
def query(self, model):
|
def query(self, model):
|
||||||
self._model = model
|
self._model = model
|
||||||
|
self._filtered = None
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def filter(self, condition):
|
def filter(self, condition):
|
||||||
# Simplistic mocking for tests, assuming equality checks
|
# Honor simple SQLAlchemy equality expressions used by these route tests.
|
||||||
for item in self._data:
|
candidates = [
|
||||||
# We assume condition is an equality expression like GitServerConfig.id == "123"
|
item
|
||||||
# It's hard to eval the condition exactly in a mock without complex parsing,
|
for item in self._data
|
||||||
# so we'll just return items where type matches.
|
if not hasattr(self, "_model") or isinstance(item, self._model)
|
||||||
pass
|
]
|
||||||
|
try:
|
||||||
|
left_key = getattr(getattr(condition, "left", None), "key", None)
|
||||||
|
right_value = getattr(getattr(condition, "right", None), "value", None)
|
||||||
|
if left_key is not None and right_value is not None:
|
||||||
|
self._filtered = [
|
||||||
|
item
|
||||||
|
for item in candidates
|
||||||
|
if getattr(item, left_key, None) == right_value
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
self._filtered = candidates
|
||||||
|
except Exception:
|
||||||
|
self._filtered = candidates
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def first(self):
|
def first(self):
|
||||||
|
if self._filtered is not None:
|
||||||
|
return self._filtered[0] if self._filtered else None
|
||||||
for item in self._data:
|
for item in self._data:
|
||||||
if hasattr(self, "_model") and isinstance(item, self._model):
|
if hasattr(self, "_model") and isinstance(item, self._model):
|
||||||
return item
|
return item
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def all(self):
|
def all(self):
|
||||||
|
if self._filtered is not None:
|
||||||
|
return list(self._filtered)
|
||||||
return self._data
|
return self._data
|
||||||
|
|
||||||
def add(self, item):
|
def add(self, item):
|
||||||
@@ -57,16 +78,27 @@ class DbMock:
|
|||||||
if not hasattr(item, "last_validated"):
|
if not hasattr(item, "last_validated"):
|
||||||
item.last_validated = "2026-03-08T00:00:00Z"
|
item.last_validated = "2026-03-08T00:00:00Z"
|
||||||
|
|
||||||
|
|
||||||
|
# [DEF:test_get_git_configs_masks_pat:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[TestGitApi]
|
||||||
def test_get_git_configs_masks_pat():
|
def test_get_git_configs_masks_pat():
|
||||||
"""
|
"""
|
||||||
@PRE: Database session `db` is available.
|
@PRE: Database session `db` is available.
|
||||||
@POST: Returns a list of all GitServerConfig objects from the database with PAT masked.
|
@POST: Returns a list of all GitServerConfig objects from the database with PAT masked.
|
||||||
"""
|
"""
|
||||||
db = DbMock([GitServerConfig(
|
db = DbMock(
|
||||||
id="config-1", name="Test Server", provider=GitProvider.GITHUB,
|
[
|
||||||
url="https://github.com", pat="secret-token",
|
GitServerConfig(
|
||||||
status=GitStatus.CONNECTED, last_validated="2026-03-08T00:00:00Z"
|
id="config-1",
|
||||||
)])
|
name="Test Server",
|
||||||
|
provider=GitProvider.GITHUB,
|
||||||
|
url="https://github.com",
|
||||||
|
pat="secret-token",
|
||||||
|
status=GitStatus.CONNECTED,
|
||||||
|
last_validated="2026-03-08T00:00:00Z",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
result = asyncio.run(git_routes.get_git_configs(db=db))
|
result = asyncio.run(git_routes.get_git_configs(db=db))
|
||||||
|
|
||||||
@@ -74,17 +106,26 @@ def test_get_git_configs_masks_pat():
|
|||||||
assert result[0].pat == "********"
|
assert result[0].pat == "********"
|
||||||
assert result[0].name == "Test Server"
|
assert result[0].name == "Test Server"
|
||||||
|
|
||||||
|
|
||||||
|
# [/DEF:test_get_git_configs_masks_pat:Function]
|
||||||
|
|
||||||
|
|
||||||
|
# [DEF:test_create_git_config_persists_config:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[TestGitApi]
|
||||||
def test_create_git_config_persists_config():
|
def test_create_git_config_persists_config():
|
||||||
"""
|
"""
|
||||||
@PRE: `config` contains valid GitServerConfigCreate data.
|
@PRE: `config` contains valid GitServerConfigCreate data.
|
||||||
@POST: A new GitServerConfig record is created in the database.
|
@POST: A new GitServerConfig record is created in the database.
|
||||||
"""
|
"""
|
||||||
from src.api.routes.git_schemas import GitServerConfigCreate
|
from src.api.routes.git_schemas import GitServerConfigCreate
|
||||||
|
|
||||||
db = DbMock()
|
db = DbMock()
|
||||||
config = GitServerConfigCreate(
|
config = GitServerConfigCreate(
|
||||||
name="New Server", provider=GitProvider.GITLAB,
|
name="New Server",
|
||||||
url="https://gitlab.com", pat="new-token",
|
provider=GitProvider.GITLAB,
|
||||||
default_branch="master"
|
url="https://gitlab.com",
|
||||||
|
pat="new-token",
|
||||||
|
default_branch="master",
|
||||||
)
|
)
|
||||||
|
|
||||||
result = asyncio.run(git_routes.create_git_config(config=config, db=db))
|
result = asyncio.run(git_routes.create_git_config(config=config, db=db))
|
||||||
@@ -93,37 +134,71 @@ def test_create_git_config_persists_config():
|
|||||||
assert db._added[0].name == "New Server"
|
assert db._added[0].name == "New Server"
|
||||||
assert db._added[0].pat == "new-token"
|
assert db._added[0].pat == "new-token"
|
||||||
assert result.name == "New Server"
|
assert result.name == "New Server"
|
||||||
assert result.pat == "new-token" # Note: route returns unmasked until serialized by FastAPI usually, but in tests schema might catch it or not.
|
assert (
|
||||||
|
result.pat == "new-token"
|
||||||
|
) # Note: route returns unmasked until serialized by FastAPI usually, but in tests schema might catch it or not.
|
||||||
|
|
||||||
|
|
||||||
|
# [/DEF:test_create_git_config_persists_config:Function]
|
||||||
|
|
||||||
from src.api.routes.git_schemas import GitServerConfigUpdate
|
from src.api.routes.git_schemas import GitServerConfigUpdate
|
||||||
|
|
||||||
|
|
||||||
|
# [DEF:test_update_git_config_modifies_record:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[TestGitApi]
|
||||||
def test_update_git_config_modifies_record():
|
def test_update_git_config_modifies_record():
|
||||||
"""
|
"""
|
||||||
@PRE: `config_id` corresponds to an existing configuration.
|
@PRE: `config_id` corresponds to an existing configuration.
|
||||||
@POST: The configuration record is updated in the database, preserving PAT if masked is sent.
|
@POST: The configuration record is updated in the database, preserving PAT if masked is sent.
|
||||||
"""
|
"""
|
||||||
existing_config = GitServerConfig(
|
existing_config = GitServerConfig(
|
||||||
id="config-1", name="Old Server", provider=GitProvider.GITHUB,
|
id="config-1",
|
||||||
url="https://github.com", pat="old-token",
|
name="Old Server",
|
||||||
status=GitStatus.CONNECTED, last_validated="2026-03-08T00:00:00Z"
|
provider=GitProvider.GITHUB,
|
||||||
|
url="https://github.com",
|
||||||
|
pat="old-token",
|
||||||
|
status=GitStatus.CONNECTED,
|
||||||
|
last_validated="2026-03-08T00:00:00Z",
|
||||||
)
|
)
|
||||||
|
|
||||||
# The monkeypatched query will return existing_config as it's the only one in the list
|
# The monkeypatched query will return existing_config as it's the only one in the list
|
||||||
class SingleConfigDbMock:
|
class SingleConfigDbMock:
|
||||||
def query(self, *args): return self
|
def query(self, *args):
|
||||||
def filter(self, *args): return self
|
return self
|
||||||
def first(self): return existing_config
|
|
||||||
def commit(self): pass
|
def filter(self, *args):
|
||||||
def refresh(self, config): pass
|
return self
|
||||||
|
|
||||||
|
def first(self):
|
||||||
|
return existing_config
|
||||||
|
|
||||||
|
def commit(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def refresh(self, config):
|
||||||
|
pass
|
||||||
|
|
||||||
db = SingleConfigDbMock()
|
db = SingleConfigDbMock()
|
||||||
update_data = GitServerConfigUpdate(name="Updated Server", pat="********")
|
update_data = GitServerConfigUpdate(name="Updated Server", pat="********")
|
||||||
|
|
||||||
result = asyncio.run(git_routes.update_git_config(config_id="config-1", config_update=update_data, db=db))
|
result = asyncio.run(
|
||||||
|
git_routes.update_git_config(
|
||||||
|
config_id="config-1", config_update=update_data, db=db
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
assert existing_config.name == "Updated Server"
|
assert existing_config.name == "Updated Server"
|
||||||
assert existing_config.pat == "old-token" # Ensure PAT is not overwritten with asterisks
|
assert (
|
||||||
|
existing_config.pat == "old-token"
|
||||||
|
) # Ensure PAT is not overwritten with asterisks
|
||||||
assert result.pat == "********"
|
assert result.pat == "********"
|
||||||
|
|
||||||
|
|
||||||
|
# [/DEF:test_update_git_config_modifies_record:Function]
|
||||||
|
|
||||||
|
|
||||||
|
# [DEF:test_update_git_config_raises_404_if_not_found:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[TestGitApi]
|
||||||
def test_update_git_config_raises_404_if_not_found():
|
def test_update_git_config_raises_404_if_not_found():
|
||||||
"""
|
"""
|
||||||
@PRE: `config_id` corresponds to a missing configuration.
|
@PRE: `config_id` corresponds to a missing configuration.
|
||||||
@@ -133,23 +208,43 @@ def test_update_git_config_raises_404_if_not_found():
|
|||||||
update_data = GitServerConfigUpdate(name="Updated Server", pat="new-token")
|
update_data = GitServerConfigUpdate(name="Updated Server", pat="new-token")
|
||||||
|
|
||||||
with pytest.raises(HTTPException) as exc_info:
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
asyncio.run(git_routes.update_git_config(config_id="config-1", config_update=update_data, db=db))
|
asyncio.run(
|
||||||
|
git_routes.update_git_config(
|
||||||
|
config_id="config-1", config_update=update_data, db=db
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
assert exc_info.value.status_code == 404
|
assert exc_info.value.status_code == 404
|
||||||
assert exc_info.value.detail == "Configuration not found"
|
assert exc_info.value.detail == "Configuration not found"
|
||||||
|
|
||||||
|
|
||||||
|
# [/DEF:test_update_git_config_raises_404_if_not_found:Function]
|
||||||
|
|
||||||
|
|
||||||
|
# [DEF:test_delete_git_config_removes_record:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[TestGitApi]
|
||||||
def test_delete_git_config_removes_record():
|
def test_delete_git_config_removes_record():
|
||||||
"""
|
"""
|
||||||
@PRE: `config_id` corresponds to an existing configuration.
|
@PRE: `config_id` corresponds to an existing configuration.
|
||||||
@POST: The configuration record is removed from the database.
|
@POST: The configuration record is removed from the database.
|
||||||
"""
|
"""
|
||||||
existing_config = GitServerConfig(id="config-1")
|
existing_config = GitServerConfig(id="config-1")
|
||||||
|
|
||||||
class SingleConfigDbMock:
|
class SingleConfigDbMock:
|
||||||
def query(self, *args): return self
|
def query(self, *args):
|
||||||
def filter(self, *args): return self
|
return self
|
||||||
def first(self): return existing_config
|
|
||||||
def delete(self, config): self.deleted = config
|
def filter(self, *args):
|
||||||
def commit(self): pass
|
return self
|
||||||
|
|
||||||
|
def first(self):
|
||||||
|
return existing_config
|
||||||
|
|
||||||
|
def delete(self, config):
|
||||||
|
self.deleted = config
|
||||||
|
|
||||||
|
def commit(self):
|
||||||
|
pass
|
||||||
|
|
||||||
db = SingleConfigDbMock()
|
db = SingleConfigDbMock()
|
||||||
|
|
||||||
@@ -158,11 +253,18 @@ def test_delete_git_config_removes_record():
|
|||||||
assert db.deleted == existing_config
|
assert db.deleted == existing_config
|
||||||
assert result["status"] == "success"
|
assert result["status"] == "success"
|
||||||
|
|
||||||
|
|
||||||
|
# [/DEF:test_delete_git_config_removes_record:Function]
|
||||||
|
|
||||||
|
|
||||||
|
# [DEF:test_test_git_config_validates_connection_successfully:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[TestGitApi]
|
||||||
def test_test_git_config_validates_connection_successfully(monkeypatch):
|
def test_test_git_config_validates_connection_successfully(monkeypatch):
|
||||||
"""
|
"""
|
||||||
@PRE: `config` contains provider, url, and pat.
|
@PRE: `config` contains provider, url, and pat.
|
||||||
@POST: Returns success if the connection is validated via GitService.
|
@POST: Returns success if the connection is validated via GitService.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
class MockGitService:
|
class MockGitService:
|
||||||
async def test_connection(self, provider, url, pat):
|
async def test_connection(self, provider, url, pat):
|
||||||
return True
|
return True
|
||||||
@@ -171,8 +273,10 @@ def test_test_git_config_validates_connection_successfully(monkeypatch):
|
|||||||
from src.api.routes.git_schemas import GitServerConfigCreate
|
from src.api.routes.git_schemas import GitServerConfigCreate
|
||||||
|
|
||||||
config = GitServerConfigCreate(
|
config = GitServerConfigCreate(
|
||||||
name="Test Server", provider=GitProvider.GITHUB,
|
name="Test Server",
|
||||||
url="https://github.com", pat="test-pat"
|
provider=GitProvider.GITHUB,
|
||||||
|
url="https://github.com",
|
||||||
|
pat="test-pat",
|
||||||
)
|
)
|
||||||
db = DbMock([])
|
db = DbMock([])
|
||||||
|
|
||||||
@@ -180,11 +284,18 @@ def test_test_git_config_validates_connection_successfully(monkeypatch):
|
|||||||
|
|
||||||
assert result["status"] == "success"
|
assert result["status"] == "success"
|
||||||
|
|
||||||
|
|
||||||
|
# [/DEF:test_test_git_config_validates_connection_successfully:Function]
|
||||||
|
|
||||||
|
|
||||||
|
# [DEF:test_test_git_config_fails_validation:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[TestGitApi]
|
||||||
def test_test_git_config_fails_validation(monkeypatch):
|
def test_test_git_config_fails_validation(monkeypatch):
|
||||||
"""
|
"""
|
||||||
@PRE: `config` contains provider, url, and pat BUT connection fails.
|
@PRE: `config` contains provider, url, and pat BUT connection fails.
|
||||||
@THROW: HTTPException 400
|
@THROW: HTTPException 400
|
||||||
"""
|
"""
|
||||||
|
|
||||||
class MockGitService:
|
class MockGitService:
|
||||||
async def test_connection(self, provider, url, pat):
|
async def test_connection(self, provider, url, pat):
|
||||||
return False
|
return False
|
||||||
@@ -193,8 +304,10 @@ def test_test_git_config_fails_validation(monkeypatch):
|
|||||||
from src.api.routes.git_schemas import GitServerConfigCreate
|
from src.api.routes.git_schemas import GitServerConfigCreate
|
||||||
|
|
||||||
config = GitServerConfigCreate(
|
config = GitServerConfigCreate(
|
||||||
name="Test Server", provider=GitProvider.GITHUB,
|
name="Test Server",
|
||||||
url="https://github.com", pat="bad-pat"
|
provider=GitProvider.GITHUB,
|
||||||
|
url="https://github.com",
|
||||||
|
pat="bad-pat",
|
||||||
)
|
)
|
||||||
db = DbMock([])
|
db = DbMock([])
|
||||||
|
|
||||||
@@ -204,36 +317,59 @@ def test_test_git_config_fails_validation(monkeypatch):
|
|||||||
assert exc_info.value.status_code == 400
|
assert exc_info.value.status_code == 400
|
||||||
assert exc_info.value.detail == "Connection failed"
|
assert exc_info.value.detail == "Connection failed"
|
||||||
|
|
||||||
|
|
||||||
|
# [/DEF:test_test_git_config_fails_validation:Function]
|
||||||
|
|
||||||
|
|
||||||
|
# [DEF:test_list_gitea_repositories_returns_payload:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[TestGitApi]
|
||||||
def test_list_gitea_repositories_returns_payload(monkeypatch):
|
def test_list_gitea_repositories_returns_payload(monkeypatch):
|
||||||
"""
|
"""
|
||||||
@PRE: config_id exists and provider is GITEA.
|
@PRE: config_id exists and provider is GITEA.
|
||||||
@POST: Returns repositories visible to PAT user.
|
@POST: Returns repositories visible to PAT user.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
class MockGitService:
|
class MockGitService:
|
||||||
async def list_gitea_repositories(self, url, pat):
|
async def list_gitea_repositories(self, url, pat):
|
||||||
return [{"name": "test-repo", "full_name": "owner/test-repo", "private": True}]
|
return [
|
||||||
|
{"name": "test-repo", "full_name": "owner/test-repo", "private": True}
|
||||||
|
]
|
||||||
|
|
||||||
monkeypatch.setattr(git_routes, "git_service", MockGitService())
|
monkeypatch.setattr(git_routes, "git_service", MockGitService())
|
||||||
existing_config = GitServerConfig(
|
existing_config = GitServerConfig(
|
||||||
id="config-1", name="Gitea Server", provider=GitProvider.GITEA,
|
id="config-1",
|
||||||
url="https://gitea.local", pat="gitea-token"
|
name="Gitea Server",
|
||||||
|
provider=GitProvider.GITEA,
|
||||||
|
url="https://gitea.local",
|
||||||
|
pat="gitea-token",
|
||||||
)
|
)
|
||||||
db = DbMock([existing_config])
|
db = DbMock([existing_config])
|
||||||
|
|
||||||
result = asyncio.run(git_routes.list_gitea_repositories(config_id="config-1", db=db))
|
result = asyncio.run(
|
||||||
|
git_routes.list_gitea_repositories(config_id="config-1", db=db)
|
||||||
|
)
|
||||||
|
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
assert result[0].name == "test-repo"
|
assert result[0].name == "test-repo"
|
||||||
assert result[0].private is True
|
assert result[0].private is True
|
||||||
|
|
||||||
|
|
||||||
|
# [/DEF:test_list_gitea_repositories_returns_payload:Function]
|
||||||
|
|
||||||
|
|
||||||
|
# [DEF:test_list_gitea_repositories_rejects_non_gitea:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[TestGitApi]
|
||||||
def test_list_gitea_repositories_rejects_non_gitea(monkeypatch):
|
def test_list_gitea_repositories_rejects_non_gitea(monkeypatch):
|
||||||
"""
|
"""
|
||||||
@PRE: config_id exists and provider is NOT GITEA.
|
@PRE: config_id exists and provider is NOT GITEA.
|
||||||
@THROW: HTTPException 400
|
@THROW: HTTPException 400
|
||||||
"""
|
"""
|
||||||
existing_config = GitServerConfig(
|
existing_config = GitServerConfig(
|
||||||
id="config-1", name="GitHub Server", provider=GitProvider.GITHUB,
|
id="config-1",
|
||||||
url="https://github.com", pat="token"
|
name="GitHub Server",
|
||||||
|
provider=GitProvider.GITHUB,
|
||||||
|
url="https://github.com",
|
||||||
|
pat="token",
|
||||||
)
|
)
|
||||||
db = DbMock([existing_config])
|
db = DbMock([existing_config])
|
||||||
|
|
||||||
@@ -243,36 +379,58 @@ def test_list_gitea_repositories_rejects_non_gitea(monkeypatch):
|
|||||||
assert exc_info.value.status_code == 400
|
assert exc_info.value.status_code == 400
|
||||||
assert "GITEA provider only" in exc_info.value.detail
|
assert "GITEA provider only" in exc_info.value.detail
|
||||||
|
|
||||||
|
|
||||||
|
# [/DEF:test_list_gitea_repositories_rejects_non_gitea:Function]
|
||||||
|
|
||||||
|
|
||||||
|
# [DEF:test_create_remote_repository_creates_provider_repo:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[TestGitApi]
|
||||||
def test_create_remote_repository_creates_provider_repo(monkeypatch):
|
def test_create_remote_repository_creates_provider_repo(monkeypatch):
|
||||||
"""
|
"""
|
||||||
@PRE: config_id exists and PAT has creation permissions.
|
@PRE: config_id exists and PAT has creation permissions.
|
||||||
@POST: Returns normalized remote repository payload.
|
@POST: Returns normalized remote repository payload.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
class MockGitService:
|
class MockGitService:
|
||||||
async def create_gitlab_repository(self, server_url, pat, name, private, description, auto_init, default_branch):
|
async def create_gitlab_repository(
|
||||||
|
self, server_url, pat, name, private, description, auto_init, default_branch
|
||||||
|
):
|
||||||
return {
|
return {
|
||||||
"name": name,
|
"name": name,
|
||||||
"full_name": f"user/{name}",
|
"full_name": f"user/{name}",
|
||||||
"private": private,
|
"private": private,
|
||||||
"clone_url": f"{server_url}/user/{name}.git"
|
"clone_url": f"{server_url}/user/{name}.git",
|
||||||
}
|
}
|
||||||
|
|
||||||
monkeypatch.setattr(git_routes, "git_service", MockGitService())
|
monkeypatch.setattr(git_routes, "git_service", MockGitService())
|
||||||
from src.api.routes.git_schemas import RemoteRepoCreateRequest
|
from src.api.routes.git_schemas import RemoteRepoCreateRequest
|
||||||
|
|
||||||
existing_config = GitServerConfig(
|
existing_config = GitServerConfig(
|
||||||
id="config-1", name="GitLab Server", provider=GitProvider.GITLAB,
|
id="config-1",
|
||||||
url="https://gitlab.com", pat="token"
|
name="GitLab Server",
|
||||||
|
provider=GitProvider.GITLAB,
|
||||||
|
url="https://gitlab.com",
|
||||||
|
pat="token",
|
||||||
)
|
)
|
||||||
db = DbMock([existing_config])
|
db = DbMock([existing_config])
|
||||||
|
|
||||||
request = RemoteRepoCreateRequest(name="new-repo", private=True, description="desc")
|
request = RemoteRepoCreateRequest(name="new-repo", private=True, description="desc")
|
||||||
result = asyncio.run(git_routes.create_remote_repository(config_id="config-1", request=request, db=db))
|
result = asyncio.run(
|
||||||
|
git_routes.create_remote_repository(
|
||||||
|
config_id="config-1", request=request, db=db
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
assert result.provider == GitProvider.GITLAB
|
assert result.provider == GitProvider.GITLAB
|
||||||
assert result.name == "new-repo"
|
assert result.name == "new-repo"
|
||||||
assert result.full_name == "user/new-repo"
|
assert result.full_name == "user/new-repo"
|
||||||
|
|
||||||
|
|
||||||
|
# [/DEF:test_create_remote_repository_creates_provider_repo:Function]
|
||||||
|
|
||||||
|
|
||||||
|
# [DEF:test_init_repository_initializes_and_saves_binding:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[TestGitApi]
|
||||||
def test_init_repository_initializes_and_saves_binding(monkeypatch):
|
def test_init_repository_initializes_and_saves_binding(monkeypatch):
|
||||||
"""
|
"""
|
||||||
@PRE: `dashboard_ref` exists and `init_data` contains valid config_id and remote_url.
|
@PRE: `dashboard_ref` exists and `init_data` contains valid config_id and remote_url.
|
||||||
@@ -283,23 +441,40 @@ def test_init_repository_initializes_and_saves_binding(monkeypatch):
|
|||||||
class MockGitService:
|
class MockGitService:
|
||||||
def init_repo(self, dashboard_id, remote_url, pat, repo_key, default_branch):
|
def init_repo(self, dashboard_id, remote_url, pat, repo_key, default_branch):
|
||||||
self.init_called = True
|
self.init_called = True
|
||||||
|
|
||||||
def _get_repo_path(self, dashboard_id, repo_key):
|
def _get_repo_path(self, dashboard_id, repo_key):
|
||||||
return f"/tmp/repos/{repo_key}"
|
return f"/tmp/repos/{repo_key}"
|
||||||
|
|
||||||
git_service_mock = MockGitService()
|
git_service_mock = MockGitService()
|
||||||
monkeypatch.setattr(git_routes, "git_service", git_service_mock)
|
monkeypatch.setattr(git_routes, "git_service", git_service_mock)
|
||||||
monkeypatch.setattr(git_routes, "_resolve_dashboard_id_from_ref", lambda *args, **kwargs: 123)
|
monkeypatch.setattr(
|
||||||
monkeypatch.setattr(git_routes, "_resolve_repo_key_from_ref", lambda *args, **kwargs: "dashboard-123")
|
git_routes, "_resolve_dashboard_id_from_ref", lambda *args, **kwargs: 123
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
git_routes,
|
||||||
|
"_resolve_repo_key_from_ref",
|
||||||
|
lambda *args, **kwargs: "dashboard-123",
|
||||||
|
)
|
||||||
|
|
||||||
existing_config = GitServerConfig(
|
existing_config = GitServerConfig(
|
||||||
id="config-1", name="GitLab Server", provider=GitProvider.GITLAB,
|
id="config-1",
|
||||||
url="https://gitlab.com", pat="token", default_branch="main"
|
name="GitLab Server",
|
||||||
|
provider=GitProvider.GITLAB,
|
||||||
|
url="https://gitlab.com",
|
||||||
|
pat="token",
|
||||||
|
default_branch="main",
|
||||||
)
|
)
|
||||||
db = DbMock([existing_config])
|
db = DbMock([existing_config])
|
||||||
|
|
||||||
init_data = RepoInitRequest(config_id="config-1", remote_url="https://git.local/repo.git")
|
init_data = RepoInitRequest(
|
||||||
|
config_id="config-1", remote_url="https://git.local/repo.git"
|
||||||
|
)
|
||||||
|
|
||||||
result = asyncio.run(git_routes.init_repository(dashboard_ref="123", init_data=init_data, config_manager=MagicMock(), db=db))
|
result = asyncio.run(
|
||||||
|
git_routes.init_repository(
|
||||||
|
dashboard_ref="123", init_data=init_data, config_manager=MagicMock(), db=db
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
assert result["status"] == "success"
|
assert result["status"] == "success"
|
||||||
assert git_service_mock.init_called is True
|
assert git_service_mock.init_called is True
|
||||||
@@ -307,4 +482,6 @@ def test_init_repository_initializes_and_saves_binding(monkeypatch):
|
|||||||
assert isinstance(db._added[0], GitRepository)
|
assert isinstance(db._added[0], GitRepository)
|
||||||
assert db._added[0].dashboard_id == 123
|
assert db._added[0].dashboard_id == 123
|
||||||
|
|
||||||
# [/DEF:backend.src.api.routes.__tests__.test_git_api:Module]
|
|
||||||
|
# [/DEF:test_init_repository_initializes_and_saves_binding:Function]
|
||||||
|
# [/DEF:TestGitApi:Module]
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
|||||||
# [DEF:backend.src.plugins.llm_analysis.__tests__.test_screenshot_service:Module]
|
# [DEF:TestScreenshotService:Module]
|
||||||
|
# @RELATION: VERIFIES ->[src.plugins.llm_analysis.service.ScreenshotService]
|
||||||
# @COMPLEXITY: 3
|
# @COMPLEXITY: 3
|
||||||
# @SEMANTICS: tests, screenshot-service, navigation, timeout-regression
|
# @SEMANTICS: tests, screenshot-service, navigation, timeout-regression
|
||||||
# @PURPOSE: Protect dashboard screenshot navigation from brittle networkidle waits.
|
# @PURPOSE: Protect dashboard screenshot navigation from brittle networkidle waits.
|
||||||
@@ -9,6 +10,7 @@ from src.plugins.llm_analysis.service import ScreenshotService
|
|||||||
|
|
||||||
|
|
||||||
# [DEF:test_iter_login_roots_includes_child_frames:Function]
|
# [DEF:test_iter_login_roots_includes_child_frames:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[TestScreenshotService]
|
||||||
# @PURPOSE: Login discovery must search embedded auth frames, not only the main page.
|
# @PURPOSE: Login discovery must search embedded auth frames, not only the main page.
|
||||||
# @PRE: Page exposes child frames list.
|
# @PRE: Page exposes child frames list.
|
||||||
# @POST: Returned roots include page plus child frames in order.
|
# @POST: Returned roots include page plus child frames in order.
|
||||||
@@ -21,10 +23,13 @@ def test_iter_login_roots_includes_child_frames():
|
|||||||
roots = service._iter_login_roots(fake_page)
|
roots = service._iter_login_roots(fake_page)
|
||||||
|
|
||||||
assert roots == [fake_page, frame_a, frame_b]
|
assert roots == [fake_page, frame_a, frame_b]
|
||||||
|
|
||||||
|
|
||||||
# [/DEF:test_iter_login_roots_includes_child_frames:Function]
|
# [/DEF:test_iter_login_roots_includes_child_frames:Function]
|
||||||
|
|
||||||
|
|
||||||
# [DEF:test_response_looks_like_login_page_detects_login_markup:Function]
|
# [DEF:test_response_looks_like_login_page_detects_login_markup:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[TestScreenshotService]
|
||||||
# @PURPOSE: Direct login fallback must reject responses that render the login screen again.
|
# @PURPOSE: Direct login fallback must reject responses that render the login screen again.
|
||||||
# @PRE: Response body contains stable login-page markers.
|
# @PRE: Response body contains stable login-page markers.
|
||||||
# @POST: Helper returns True so caller treats fallback as failed authentication.
|
# @POST: Helper returns True so caller treats fallback as failed authentication.
|
||||||
@@ -45,10 +50,13 @@ def test_response_looks_like_login_page_detects_login_markup():
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert result is True
|
assert result is True
|
||||||
|
|
||||||
|
|
||||||
# [/DEF:test_response_looks_like_login_page_detects_login_markup:Function]
|
# [/DEF:test_response_looks_like_login_page_detects_login_markup:Function]
|
||||||
|
|
||||||
|
|
||||||
# [DEF:test_find_first_visible_locator_skips_hidden_first_match:Function]
|
# [DEF:test_find_first_visible_locator_skips_hidden_first_match:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[TestScreenshotService]
|
||||||
# @PURPOSE: Locator helper must not reject a selector collection just because its first element is hidden.
|
# @PURPOSE: Locator helper must not reject a selector collection just because its first element is hidden.
|
||||||
# @PRE: First matched element is hidden and second matched element is visible.
|
# @PRE: First matched element is hidden and second matched element is visible.
|
||||||
# @POST: Helper returns the second visible candidate.
|
# @POST: Helper returns the second visible candidate.
|
||||||
@@ -73,18 +81,23 @@ async def test_find_first_visible_locator_skips_hidden_first_match():
|
|||||||
return self._elements[index]
|
return self._elements[index]
|
||||||
|
|
||||||
service = ScreenshotService(env=type("Env", (), {})())
|
service = ScreenshotService(env=type("Env", (), {})())
|
||||||
hidden_then_visible = _FakeLocator([
|
hidden_then_visible = _FakeLocator(
|
||||||
|
[
|
||||||
_FakeElement(False, "hidden"),
|
_FakeElement(False, "hidden"),
|
||||||
_FakeElement(True, "visible"),
|
_FakeElement(True, "visible"),
|
||||||
])
|
]
|
||||||
|
)
|
||||||
|
|
||||||
result = await service._find_first_visible_locator([hidden_then_visible])
|
result = await service._find_first_visible_locator([hidden_then_visible])
|
||||||
|
|
||||||
assert result.label == "visible"
|
assert result.label == "visible"
|
||||||
|
|
||||||
|
|
||||||
# [/DEF:test_find_first_visible_locator_skips_hidden_first_match:Function]
|
# [/DEF:test_find_first_visible_locator_skips_hidden_first_match:Function]
|
||||||
|
|
||||||
|
|
||||||
# [DEF:test_submit_login_via_form_post_uses_browser_context_request:Function]
|
# [DEF:test_submit_login_via_form_post_uses_browser_context_request:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[TestScreenshotService]
|
||||||
# @PURPOSE: Fallback login must submit hidden fields and credentials through the context request cookie jar.
|
# @PURPOSE: Fallback login must submit hidden fields and credentials through the context request cookie jar.
|
||||||
# @PRE: Login DOM exposes csrf hidden field and request context returns authenticated HTML.
|
# @PRE: Login DOM exposes csrf hidden field and request context returns authenticated HTML.
|
||||||
# @POST: Helper returns True and request payload contains csrf_token plus credentials plus request options.
|
# @POST: Helper returns True and request payload contains csrf_token plus credentials plus request options.
|
||||||
@@ -122,15 +135,25 @@ async def test_submit_login_via_form_post_uses_browser_context_request():
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.calls = []
|
self.calls = []
|
||||||
|
|
||||||
async def post(self, url, form=None, headers=None, timeout=None, fail_on_status_code=None, max_redirects=None):
|
async def post(
|
||||||
self.calls.append({
|
self,
|
||||||
|
url,
|
||||||
|
form=None,
|
||||||
|
headers=None,
|
||||||
|
timeout=None,
|
||||||
|
fail_on_status_code=None,
|
||||||
|
max_redirects=None,
|
||||||
|
):
|
||||||
|
self.calls.append(
|
||||||
|
{
|
||||||
"url": url,
|
"url": url,
|
||||||
"form": dict(form or {}),
|
"form": dict(form or {}),
|
||||||
"headers": dict(headers or {}),
|
"headers": dict(headers or {}),
|
||||||
"timeout": timeout,
|
"timeout": timeout,
|
||||||
"fail_on_status_code": fail_on_status_code,
|
"fail_on_status_code": fail_on_status_code,
|
||||||
"max_redirects": max_redirects,
|
"max_redirects": max_redirects,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
return _FakeResponse()
|
return _FakeResponse()
|
||||||
|
|
||||||
class _FakeContext:
|
class _FakeContext:
|
||||||
@@ -144,20 +167,25 @@ async def test_submit_login_via_form_post_uses_browser_context_request():
|
|||||||
|
|
||||||
def locator(self, selector):
|
def locator(self, selector):
|
||||||
if selector == "input[type='hidden'][name]":
|
if selector == "input[type='hidden'][name]":
|
||||||
return _FakeLocator([
|
return _FakeLocator(
|
||||||
|
[
|
||||||
_FakeInput("csrf_token", "csrf-123"),
|
_FakeInput("csrf_token", "csrf-123"),
|
||||||
_FakeInput("next", "/superset/welcome/"),
|
_FakeInput("next", "/superset/welcome/"),
|
||||||
])
|
]
|
||||||
|
)
|
||||||
return _FakeLocator([])
|
return _FakeLocator([])
|
||||||
|
|
||||||
env = type("Env", (), {"username": "admin", "password": "secret"})()
|
env = type("Env", (), {"username": "admin", "password": "secret"})()
|
||||||
service = ScreenshotService(env=env)
|
service = ScreenshotService(env=env)
|
||||||
page = _FakePage()
|
page = _FakePage()
|
||||||
|
|
||||||
result = await service._submit_login_via_form_post(page, "https://example.test/login/")
|
result = await service._submit_login_via_form_post(
|
||||||
|
page, "https://example.test/login/"
|
||||||
|
)
|
||||||
|
|
||||||
assert result is True
|
assert result is True
|
||||||
assert page.context.request.calls == [{
|
assert page.context.request.calls == [
|
||||||
|
{
|
||||||
"url": "https://example.test/login/",
|
"url": "https://example.test/login/",
|
||||||
"form": {
|
"form": {
|
||||||
"csrf_token": "csrf-123",
|
"csrf_token": "csrf-123",
|
||||||
@@ -172,11 +200,15 @@ async def test_submit_login_via_form_post_uses_browser_context_request():
|
|||||||
"timeout": 10000,
|
"timeout": 10000,
|
||||||
"fail_on_status_code": False,
|
"fail_on_status_code": False,
|
||||||
"max_redirects": 0,
|
"max_redirects": 0,
|
||||||
}]
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
# [/DEF:test_submit_login_via_form_post_uses_browser_context_request:Function]
|
# [/DEF:test_submit_login_via_form_post_uses_browser_context_request:Function]
|
||||||
|
|
||||||
|
|
||||||
# [DEF:test_submit_login_via_form_post_accepts_authenticated_redirect:Function]
|
# [DEF:test_submit_login_via_form_post_accepts_authenticated_redirect:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[TestScreenshotService]
|
||||||
# @PURPOSE: Fallback login must treat non-login 302 redirect as success without waiting for redirect target.
|
# @PURPOSE: Fallback login must treat non-login 302 redirect as success without waiting for redirect target.
|
||||||
# @PRE: Request response is 302 with Location outside login path.
|
# @PRE: Request response is 302 with Location outside login path.
|
||||||
# @POST: Helper returns True.
|
# @POST: Helper returns True.
|
||||||
@@ -212,7 +244,15 @@ async def test_submit_login_via_form_post_accepts_authenticated_redirect():
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
class _FakeRequest:
|
class _FakeRequest:
|
||||||
async def post(self, url, form=None, headers=None, timeout=None, fail_on_status_code=None, max_redirects=None):
|
async def post(
|
||||||
|
self,
|
||||||
|
url,
|
||||||
|
form=None,
|
||||||
|
headers=None,
|
||||||
|
timeout=None,
|
||||||
|
fail_on_status_code=None,
|
||||||
|
max_redirects=None,
|
||||||
|
):
|
||||||
return _FakeResponse()
|
return _FakeResponse()
|
||||||
|
|
||||||
class _FakeContext:
|
class _FakeContext:
|
||||||
@@ -232,13 +272,18 @@ async def test_submit_login_via_form_post_accepts_authenticated_redirect():
|
|||||||
env = type("Env", (), {"username": "admin", "password": "secret"})()
|
env = type("Env", (), {"username": "admin", "password": "secret"})()
|
||||||
service = ScreenshotService(env=env)
|
service = ScreenshotService(env=env)
|
||||||
|
|
||||||
result = await service._submit_login_via_form_post(_FakePage(), "https://example.test/login/")
|
result = await service._submit_login_via_form_post(
|
||||||
|
_FakePage(), "https://example.test/login/"
|
||||||
|
)
|
||||||
|
|
||||||
assert result is True
|
assert result is True
|
||||||
|
|
||||||
|
|
||||||
# [/DEF:test_submit_login_via_form_post_accepts_authenticated_redirect:Function]
|
# [/DEF:test_submit_login_via_form_post_accepts_authenticated_redirect:Function]
|
||||||
|
|
||||||
|
|
||||||
# [DEF:test_submit_login_via_form_post_rejects_login_markup_response:Function]
|
# [DEF:test_submit_login_via_form_post_rejects_login_markup_response:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[TestScreenshotService]
|
||||||
# @PURPOSE: Fallback login must fail when POST response still contains login form content.
|
# @PURPOSE: Fallback login must fail when POST response still contains login form content.
|
||||||
# @PRE: Login DOM exposes csrf hidden field and request response renders login markup.
|
# @PRE: Login DOM exposes csrf hidden field and request response renders login markup.
|
||||||
# @POST: Helper returns False.
|
# @POST: Helper returns False.
|
||||||
@@ -282,7 +327,15 @@ async def test_submit_login_via_form_post_rejects_login_markup_response():
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
class _FakeRequest:
|
class _FakeRequest:
|
||||||
async def post(self, url, form=None, headers=None, timeout=None, fail_on_status_code=None, max_redirects=None):
|
async def post(
|
||||||
|
self,
|
||||||
|
url,
|
||||||
|
form=None,
|
||||||
|
headers=None,
|
||||||
|
timeout=None,
|
||||||
|
fail_on_status_code=None,
|
||||||
|
max_redirects=None,
|
||||||
|
):
|
||||||
return _FakeResponse()
|
return _FakeResponse()
|
||||||
|
|
||||||
class _FakeContext:
|
class _FakeContext:
|
||||||
@@ -302,13 +355,18 @@ async def test_submit_login_via_form_post_rejects_login_markup_response():
|
|||||||
env = type("Env", (), {"username": "admin", "password": "secret"})()
|
env = type("Env", (), {"username": "admin", "password": "secret"})()
|
||||||
service = ScreenshotService(env=env)
|
service = ScreenshotService(env=env)
|
||||||
|
|
||||||
result = await service._submit_login_via_form_post(_FakePage(), "https://example.test/login/")
|
result = await service._submit_login_via_form_post(
|
||||||
|
_FakePage(), "https://example.test/login/"
|
||||||
|
)
|
||||||
|
|
||||||
assert result is False
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
# [/DEF:test_submit_login_via_form_post_rejects_login_markup_response:Function]
|
# [/DEF:test_submit_login_via_form_post_rejects_login_markup_response:Function]
|
||||||
|
|
||||||
|
|
||||||
# [DEF:test_goto_resilient_falls_back_from_domcontentloaded_to_load:Function]
|
# [DEF:test_goto_resilient_falls_back_from_domcontentloaded_to_load:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[TestScreenshotService]
|
||||||
# @PURPOSE: Pages with unstable primary wait must retry with fallback wait strategy.
|
# @PURPOSE: Pages with unstable primary wait must retry with fallback wait strategy.
|
||||||
# @PRE: First page.goto call raises; second succeeds.
|
# @PRE: First page.goto call raises; second succeeds.
|
||||||
# @POST: Helper returns second response and attempts both wait modes in order.
|
# @POST: Helper returns second response and attempts both wait modes in order.
|
||||||
@@ -340,5 +398,7 @@ async def test_goto_resilient_falls_back_from_domcontentloaded_to_load():
|
|||||||
("https://example.test/dashboard", "domcontentloaded", 1234),
|
("https://example.test/dashboard", "domcontentloaded", 1234),
|
||||||
("https://example.test/dashboard", "load", 1234),
|
("https://example.test/dashboard", "load", 1234),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
# [/DEF:test_goto_resilient_falls_back_from_domcontentloaded_to_load:Function]
|
# [/DEF:test_goto_resilient_falls_back_from_domcontentloaded_to_load:Function]
|
||||||
# [/DEF:backend.src.plugins.llm_analysis.__tests__.test_screenshot_service:Module]
|
# [/DEF:TestScreenshotService:Module]
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ from src.models.llm import ValidationRecord
|
|||||||
# [DEF:test_health_service:Module]
|
# [DEF:test_health_service:Module]
|
||||||
# @COMPLEXITY: 3
|
# @COMPLEXITY: 3
|
||||||
# @PURPOSE: Unit tests for HealthService aggregation logic.
|
# @PURPOSE: Unit tests for HealthService aggregation logic.
|
||||||
|
# @RELATION: VERIFIES ->[src.services.health_service.HealthService]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_health_summary_aggregation():
|
async def test_get_health_summary_aggregation():
|
||||||
@@ -26,7 +28,7 @@ async def test_get_health_summary_aggregation():
|
|||||||
status="FAIL",
|
status="FAIL",
|
||||||
timestamp=now - timedelta(hours=1),
|
timestamp=now - timedelta(hours=1),
|
||||||
summary="Old failure",
|
summary="Old failure",
|
||||||
issues=[]
|
issues=[],
|
||||||
)
|
)
|
||||||
rec1_new = ValidationRecord(
|
rec1_new = ValidationRecord(
|
||||||
id="rec-new",
|
id="rec-new",
|
||||||
@@ -35,7 +37,7 @@ async def test_get_health_summary_aggregation():
|
|||||||
status="PASS",
|
status="PASS",
|
||||||
timestamp=now,
|
timestamp=now,
|
||||||
summary="New pass",
|
summary="New pass",
|
||||||
issues=[]
|
issues=[],
|
||||||
)
|
)
|
||||||
|
|
||||||
# Dashboard 2: Single WARN
|
# Dashboard 2: Single WARN
|
||||||
@@ -46,7 +48,7 @@ async def test_get_health_summary_aggregation():
|
|||||||
status="WARN",
|
status="WARN",
|
||||||
timestamp=now,
|
timestamp=now,
|
||||||
summary="Warning",
|
summary="Warning",
|
||||||
issues=[]
|
issues=[],
|
||||||
)
|
)
|
||||||
|
|
||||||
# Mock the query chain
|
# Mock the query chain
|
||||||
@@ -75,6 +77,7 @@ async def test_get_health_summary_aggregation():
|
|||||||
assert dash_1_item.record_id == rec1_new.id
|
assert dash_1_item.record_id == rec1_new.id
|
||||||
assert dash_1_item.dashboard_slug == "dash_1"
|
assert dash_1_item.dashboard_slug == "dash_1"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_health_summary_empty():
|
async def test_get_health_summary_empty():
|
||||||
"""
|
"""
|
||||||
@@ -159,6 +162,8 @@ async def test_get_health_summary_reuses_dashboard_metadata_cache_across_service
|
|||||||
HealthService._dashboard_summary_cache.clear()
|
HealthService._dashboard_summary_cache.clear()
|
||||||
|
|
||||||
|
|
||||||
|
# [DEF:test_delete_validation_report_deletes_dashboard_scope_and_linked_tasks:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[test_health_service]
|
||||||
def test_delete_validation_report_deletes_dashboard_scope_and_linked_tasks():
|
def test_delete_validation_report_deletes_dashboard_scope_and_linked_tasks():
|
||||||
db = MagicMock()
|
db = MagicMock()
|
||||||
config_manager = MagicMock()
|
config_manager = MagicMock()
|
||||||
@@ -222,12 +227,17 @@ def test_delete_validation_report_deletes_dashboard_scope_and_linked_tasks():
|
|||||||
db.commit.assert_called_once()
|
db.commit.assert_called_once()
|
||||||
cleanup_instance.delete_task_with_logs.assert_any_call("task-1")
|
cleanup_instance.delete_task_with_logs.assert_any_call("task-1")
|
||||||
cleanup_instance.delete_task_with_logs.assert_any_call("task-2")
|
cleanup_instance.delete_task_with_logs.assert_any_call("task-2")
|
||||||
cleanup_instance.delete_task_with_logs.call_count == 2
|
assert cleanup_instance.delete_task_with_logs.call_count == 2
|
||||||
assert "task-1" not in task_manager.tasks
|
assert "task-1" not in task_manager.tasks
|
||||||
assert "task-2" not in task_manager.tasks
|
assert "task-2" not in task_manager.tasks
|
||||||
assert "task-3" in task_manager.tasks
|
assert "task-3" in task_manager.tasks
|
||||||
|
|
||||||
|
|
||||||
|
# [/DEF:test_delete_validation_report_deletes_dashboard_scope_and_linked_tasks:Function]
|
||||||
|
|
||||||
|
|
||||||
|
# [DEF:test_delete_validation_report_returns_false_for_unknown_record:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[test_health_service]
|
||||||
def test_delete_validation_report_returns_false_for_unknown_record():
|
def test_delete_validation_report_returns_false_for_unknown_record():
|
||||||
db = MagicMock()
|
db = MagicMock()
|
||||||
db.query.return_value.filter.return_value.first.return_value = None
|
db.query.return_value.filter.return_value.first.return_value = None
|
||||||
@@ -237,6 +247,11 @@ def test_delete_validation_report_returns_false_for_unknown_record():
|
|||||||
assert service.delete_validation_report("missing") is False
|
assert service.delete_validation_report("missing") is False
|
||||||
|
|
||||||
|
|
||||||
|
# [/DEF:test_delete_validation_report_returns_false_for_unknown_record:Function]
|
||||||
|
|
||||||
|
|
||||||
|
# [DEF:test_delete_validation_report_swallows_linked_task_cleanup_failure:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[test_health_service]
|
||||||
def test_delete_validation_report_swallows_linked_task_cleanup_failure():
|
def test_delete_validation_report_swallows_linked_task_cleanup_failure():
|
||||||
db = MagicMock()
|
db = MagicMock()
|
||||||
config_manager = MagicMock()
|
config_manager = MagicMock()
|
||||||
@@ -264,11 +279,14 @@ def test_delete_validation_report_swallows_linked_task_cleanup_failure():
|
|||||||
|
|
||||||
db.query.side_effect = [first_query, peer_query]
|
db.query.side_effect = [first_query, peer_query]
|
||||||
|
|
||||||
with patch("src.services.health_service.TaskCleanupService") as cleanup_cls, patch(
|
with (
|
||||||
"src.services.health_service.logger"
|
patch("src.services.health_service.TaskCleanupService") as cleanup_cls,
|
||||||
) as mock_logger:
|
patch("src.services.health_service.logger") as mock_logger,
|
||||||
|
):
|
||||||
cleanup_instance = MagicMock()
|
cleanup_instance = MagicMock()
|
||||||
cleanup_instance.delete_task_with_logs.side_effect = RuntimeError("cleanup exploded")
|
cleanup_instance.delete_task_with_logs.side_effect = RuntimeError(
|
||||||
|
"cleanup exploded"
|
||||||
|
)
|
||||||
cleanup_cls.return_value = cleanup_instance
|
cleanup_cls.return_value = cleanup_instance
|
||||||
|
|
||||||
service = HealthService(db, config_manager=config_manager)
|
service = HealthService(db, config_manager=config_manager)
|
||||||
@@ -282,4 +300,5 @@ def test_delete_validation_report_swallows_linked_task_cleanup_failure():
|
|||||||
assert "task-1" not in task_manager.tasks
|
assert "task-1" not in task_manager.tasks
|
||||||
|
|
||||||
|
|
||||||
|
# [/DEF:test_delete_validation_report_swallows_linked_task_cleanup_failure:Function]
|
||||||
# [/DEF:test_health_service:Module]
|
# [/DEF:test_health_service:Module]
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
# [DEF:backend.src.services.__tests__.test_llm_plugin_persistence:Module]
|
# [DEF:test_llm_plugin_persistence:Module]
|
||||||
|
# @RELATION: VERIFIES ->[src.plugins.llm_analysis.plugin.DashboardValidationPlugin]
|
||||||
# @COMPLEXITY: 3
|
# @COMPLEXITY: 3
|
||||||
# @PURPOSE: Regression test for ValidationRecord persistence fields populated from task context.
|
# @PURPOSE: Regression test for ValidationRecord persistence fields populated from task context.
|
||||||
|
|
||||||
@@ -9,6 +10,7 @@ from src.plugins.llm_analysis import plugin as plugin_module
|
|||||||
|
|
||||||
|
|
||||||
# [DEF:_DummyLogger:Class]
|
# [DEF:_DummyLogger:Class]
|
||||||
|
# @RELATION: BINDS_TO ->[test_llm_plugin_persistence]
|
||||||
# @PURPOSE: Minimal logger shim for TaskContext-like objects used in tests.
|
# @PURPOSE: Minimal logger shim for TaskContext-like objects used in tests.
|
||||||
class _DummyLogger:
|
class _DummyLogger:
|
||||||
def with_source(self, _source: str):
|
def with_source(self, _source: str):
|
||||||
@@ -25,10 +27,13 @@ class _DummyLogger:
|
|||||||
|
|
||||||
def error(self, *_args, **_kwargs):
|
def error(self, *_args, **_kwargs):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
# [/DEF:_DummyLogger:Class]
|
# [/DEF:_DummyLogger:Class]
|
||||||
|
|
||||||
|
|
||||||
# [DEF:_FakeDBSession:Class]
|
# [DEF:_FakeDBSession:Class]
|
||||||
|
# @RELATION: BINDS_TO ->[test_llm_plugin_persistence]
|
||||||
# @PURPOSE: Captures persisted records for assertion and mimics SQLAlchemy session methods used by plugin.
|
# @PURPOSE: Captures persisted records for assertion and mimics SQLAlchemy session methods used by plugin.
|
||||||
class _FakeDBSession:
|
class _FakeDBSession:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
@@ -44,13 +49,18 @@ class _FakeDBSession:
|
|||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
self.closed = True
|
self.closed = True
|
||||||
|
|
||||||
|
|
||||||
# [/DEF:_FakeDBSession:Class]
|
# [/DEF:_FakeDBSession:Class]
|
||||||
|
|
||||||
|
|
||||||
# [DEF:test_dashboard_validation_plugin_persists_task_and_environment_ids:Function]
|
# [DEF:test_dashboard_validation_plugin_persists_task_and_environment_ids:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[test_llm_plugin_persistence]
|
||||||
# @PURPOSE: Ensure db ValidationRecord includes context.task_id and params.environment_id.
|
# @PURPOSE: Ensure db ValidationRecord includes context.task_id and params.environment_id.
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_dashboard_validation_plugin_persists_task_and_environment_ids(tmp_path, monkeypatch):
|
async def test_dashboard_validation_plugin_persists_task_and_environment_ids(
|
||||||
|
tmp_path, monkeypatch
|
||||||
|
):
|
||||||
fake_db = _FakeDBSession()
|
fake_db = _FakeDBSession()
|
||||||
|
|
||||||
env = types.SimpleNamespace(id="env-42")
|
env = types.SimpleNamespace(id="env-42")
|
||||||
@@ -112,7 +122,9 @@ async def test_dashboard_validation_plugin_persists_task_and_environment_ids(tmp
|
|||||||
|
|
||||||
class _FakeSupersetClient:
|
class _FakeSupersetClient:
|
||||||
def __init__(self, _env):
|
def __init__(self, _env):
|
||||||
self.network = types.SimpleNamespace(request=lambda **_kwargs: {"result": []})
|
self.network = types.SimpleNamespace(
|
||||||
|
request=lambda **_kwargs: {"result": []}
|
||||||
|
)
|
||||||
|
|
||||||
monkeypatch.setattr(plugin_module, "SessionLocal", lambda: fake_db)
|
monkeypatch.setattr(plugin_module, "SessionLocal", lambda: fake_db)
|
||||||
monkeypatch.setattr(plugin_module, "LLMProviderService", _FakeProviderService)
|
monkeypatch.setattr(plugin_module, "LLMProviderService", _FakeProviderService)
|
||||||
@@ -120,7 +132,9 @@ async def test_dashboard_validation_plugin_persists_task_and_environment_ids(tmp
|
|||||||
monkeypatch.setattr(plugin_module, "LLMClient", _FakeLLMClient)
|
monkeypatch.setattr(plugin_module, "LLMClient", _FakeLLMClient)
|
||||||
monkeypatch.setattr(plugin_module, "NotificationService", _FakeNotificationService)
|
monkeypatch.setattr(plugin_module, "NotificationService", _FakeNotificationService)
|
||||||
monkeypatch.setattr(plugin_module, "SupersetClient", _FakeSupersetClient)
|
monkeypatch.setattr(plugin_module, "SupersetClient", _FakeSupersetClient)
|
||||||
monkeypatch.setattr("src.dependencies.get_config_manager", lambda: _FakeConfigManager())
|
monkeypatch.setattr(
|
||||||
|
"src.dependencies.get_config_manager", lambda: _FakeConfigManager()
|
||||||
|
)
|
||||||
|
|
||||||
context = types.SimpleNamespace(
|
context = types.SimpleNamespace(
|
||||||
task_id="task-999",
|
task_id="task-999",
|
||||||
@@ -144,7 +158,9 @@ async def test_dashboard_validation_plugin_persists_task_and_environment_ids(tmp
|
|||||||
assert fake_db.added is not None
|
assert fake_db.added is not None
|
||||||
assert fake_db.added.task_id == "task-999"
|
assert fake_db.added.task_id == "task-999"
|
||||||
assert fake_db.added.environment_id == "env-42"
|
assert fake_db.added.environment_id == "env-42"
|
||||||
|
|
||||||
|
|
||||||
# [/DEF:test_dashboard_validation_plugin_persists_task_and_environment_ids:Function]
|
# [/DEF:test_dashboard_validation_plugin_persists_task_and_environment_ids:Function]
|
||||||
|
|
||||||
|
|
||||||
# [/DEF:backend.src.services.__tests__.test_llm_plugin_persistence:Module]
|
# [/DEF:test_llm_plugin_persistence:Module]
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
# [DEF:backend.src.services.__tests__.test_resource_service:Module]
|
# [DEF:TestResourceService:Module]
|
||||||
# @COMPLEXITY: 3
|
# @COMPLEXITY: 3
|
||||||
# @SEMANTICS: resource-service, tests, dashboards, datasets, activity
|
# @SEMANTICS: resource-service, tests, dashboards, datasets, activity
|
||||||
# @PURPOSE: Unit tests for ResourceService
|
# @PURPOSE: Unit tests for ResourceService
|
||||||
# @LAYER: Service
|
# @LAYER: Service
|
||||||
# @RELATION: TESTS -> backend.src.services.resource_service
|
# @RELATION: VERIFIES ->[src.services.resource_service.ResourceService]
|
||||||
# @RELATION: VERIFIES -> ResourceService
|
|
||||||
# @INVARIANT: Resource summaries preserve task linkage and status projection behavior.
|
# @INVARIANT: Resource summaries preserve task linkage and status projection behavior.
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -13,15 +12,17 @@ from datetime import datetime, timezone
|
|||||||
|
|
||||||
|
|
||||||
# [DEF:test_get_dashboards_with_status:Function]
|
# [DEF:test_get_dashboards_with_status:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[TestResourceService]
|
||||||
# @PURPOSE: Validate dashboard enrichment includes git/task status projections.
|
# @PURPOSE: Validate dashboard enrichment includes git/task status projections.
|
||||||
# @TEST: get_dashboards_with_status returns dashboards with git and task status
|
# @TEST: get_dashboards_with_status returns dashboards with git and task status
|
||||||
# @PRE: SupersetClient returns dashboard list
|
# @PRE: SupersetClient returns dashboard list
|
||||||
# @POST: Each dashboard has git_status and last_task fields
|
# @POST: Each dashboard has git_status and last_task fields
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_dashboards_with_status():
|
async def test_get_dashboards_with_status():
|
||||||
with patch("src.services.resource_service.SupersetClient") as mock_client, \
|
with (
|
||||||
patch("src.services.resource_service.GitService"):
|
patch("src.services.resource_service.SupersetClient") as mock_client,
|
||||||
|
patch("src.services.resource_service.GitService"),
|
||||||
|
):
|
||||||
from src.services.resource_service import ResourceService
|
from src.services.resource_service import ResourceService
|
||||||
|
|
||||||
service = ResourceService()
|
service = ResourceService()
|
||||||
@@ -29,7 +30,7 @@ async def test_get_dashboards_with_status():
|
|||||||
# Mock Superset response
|
# Mock Superset response
|
||||||
mock_client.return_value.get_dashboards_summary.return_value = [
|
mock_client.return_value.get_dashboards_summary.return_value = [
|
||||||
{"id": 1, "title": "Dashboard 1", "slug": "dash-1"},
|
{"id": 1, "title": "Dashboard 1", "slug": "dash-1"},
|
||||||
{"id": 2, "title": "Dashboard 2", "slug": "dash-2"}
|
{"id": 2, "title": "Dashboard 2", "slug": "dash-2"},
|
||||||
]
|
]
|
||||||
|
|
||||||
# Mock tasks
|
# Mock tasks
|
||||||
@@ -76,13 +77,13 @@ async def test_get_dashboards_with_status():
|
|||||||
|
|
||||||
|
|
||||||
# [DEF:test_get_datasets_with_status:Function]
|
# [DEF:test_get_datasets_with_status:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[TestResourceService]
|
||||||
# @TEST: get_datasets_with_status returns datasets with task status
|
# @TEST: get_datasets_with_status returns datasets with task status
|
||||||
# @PRE: SupersetClient returns dataset list
|
# @PRE: SupersetClient returns dataset list
|
||||||
# @POST: Each dataset has last_task field
|
# @POST: Each dataset has last_task field
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_datasets_with_status():
|
async def test_get_datasets_with_status():
|
||||||
with patch("src.services.resource_service.SupersetClient") as mock_client:
|
with patch("src.services.resource_service.SupersetClient") as mock_client:
|
||||||
|
|
||||||
from src.services.resource_service import ResourceService
|
from src.services.resource_service import ResourceService
|
||||||
|
|
||||||
service = ResourceService()
|
service = ResourceService()
|
||||||
@@ -90,7 +91,7 @@ async def test_get_datasets_with_status():
|
|||||||
# Mock Superset response
|
# Mock Superset response
|
||||||
mock_client.return_value.get_datasets_summary.return_value = [
|
mock_client.return_value.get_datasets_summary.return_value = [
|
||||||
{"id": 1, "table_name": "users", "schema": "public", "database": "app"},
|
{"id": 1, "table_name": "users", "schema": "public", "database": "app"},
|
||||||
{"id": 2, "table_name": "orders", "schema": "public", "database": "app"}
|
{"id": 2, "table_name": "orders", "schema": "public", "database": "app"},
|
||||||
]
|
]
|
||||||
|
|
||||||
# Mock tasks
|
# Mock tasks
|
||||||
@@ -116,6 +117,7 @@ async def test_get_datasets_with_status():
|
|||||||
|
|
||||||
|
|
||||||
# [DEF:test_get_activity_summary:Function]
|
# [DEF:test_get_activity_summary:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[TestResourceService]
|
||||||
# @TEST: get_activity_summary returns active count and recent tasks
|
# @TEST: get_activity_summary returns active count and recent tasks
|
||||||
# @PRE: tasks list provided
|
# @PRE: tasks list provided
|
||||||
# @POST: Returns dict with active_count and recent_tasks
|
# @POST: Returns dict with active_count and recent_tasks
|
||||||
@@ -153,12 +155,12 @@ def test_get_activity_summary():
|
|||||||
|
|
||||||
|
|
||||||
# [DEF:test_get_git_status_for_dashboard_no_repo:Function]
|
# [DEF:test_get_git_status_for_dashboard_no_repo:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[TestResourceService]
|
||||||
# @TEST: _get_git_status_for_dashboard returns None when no repo exists
|
# @TEST: _get_git_status_for_dashboard returns None when no repo exists
|
||||||
# @PRE: GitService returns None for repo
|
# @PRE: GitService returns None for repo
|
||||||
# @POST: Returns None
|
# @POST: Returns None
|
||||||
def test_get_git_status_for_dashboard_no_repo():
|
def test_get_git_status_for_dashboard_no_repo():
|
||||||
with patch("src.services.resource_service.GitService") as mock_git:
|
with patch("src.services.resource_service.GitService") as mock_git:
|
||||||
|
|
||||||
from src.services.resource_service import ResourceService
|
from src.services.resource_service import ResourceService
|
||||||
|
|
||||||
service = ResourceService()
|
service = ResourceService()
|
||||||
@@ -167,14 +169,15 @@ def test_get_git_status_for_dashboard_no_repo():
|
|||||||
result = service._get_git_status_for_dashboard(123)
|
result = service._get_git_status_for_dashboard(123)
|
||||||
|
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert result['sync_status'] == 'NO_REPO'
|
assert result["sync_status"] == "NO_REPO"
|
||||||
assert result['has_repo'] is False
|
assert result["has_repo"] is False
|
||||||
|
|
||||||
|
|
||||||
# [/DEF:test_get_git_status_for_dashboard_no_repo:Function]
|
# [/DEF:test_get_git_status_for_dashboard_no_repo:Function]
|
||||||
|
|
||||||
|
|
||||||
# [DEF:test_get_last_task_for_resource:Function]
|
# [DEF:test_get_last_task_for_resource:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[TestResourceService]
|
||||||
# @TEST: _get_last_task_for_resource returns most recent task for resource
|
# @TEST: _get_last_task_for_resource returns most recent task for resource
|
||||||
# @PRE: tasks list with matching resource_id
|
# @PRE: tasks list with matching resource_id
|
||||||
# @POST: Returns task summary with task_id and status
|
# @POST: Returns task summary with task_id and status
|
||||||
@@ -207,6 +210,7 @@ def test_get_last_task_for_resource():
|
|||||||
|
|
||||||
|
|
||||||
# [DEF:test_extract_resource_name_from_task:Function]
|
# [DEF:test_extract_resource_name_from_task:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[TestResourceService]
|
||||||
# @TEST: _extract_resource_name_from_task extracts name from params
|
# @TEST: _extract_resource_name_from_task extracts name from params
|
||||||
# @PRE: task has resource_name in params
|
# @PRE: task has resource_name in params
|
||||||
# @POST: Returns resource name or fallback
|
# @POST: Returns resource name or fallback
|
||||||
@@ -236,6 +240,7 @@ def test_extract_resource_name_from_task():
|
|||||||
|
|
||||||
|
|
||||||
# [DEF:test_get_last_task_for_resource_empty_tasks:Function]
|
# [DEF:test_get_last_task_for_resource_empty_tasks:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[TestResourceService]
|
||||||
# @TEST: _get_last_task_for_resource returns None for empty tasks list
|
# @TEST: _get_last_task_for_resource returns None for empty tasks list
|
||||||
# @PRE: tasks is empty list
|
# @PRE: tasks is empty list
|
||||||
# @POST: Returns None
|
# @POST: Returns None
|
||||||
@@ -246,10 +251,13 @@ def test_get_last_task_for_resource_empty_tasks():
|
|||||||
|
|
||||||
result = service._get_last_task_for_resource("dashboard-1", [])
|
result = service._get_last_task_for_resource("dashboard-1", [])
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
# [/DEF:test_get_last_task_for_resource_empty_tasks:Function]
|
# [/DEF:test_get_last_task_for_resource_empty_tasks:Function]
|
||||||
|
|
||||||
|
|
||||||
# [DEF:test_get_last_task_for_resource_no_match:Function]
|
# [DEF:test_get_last_task_for_resource_no_match:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[TestResourceService]
|
||||||
# @TEST: _get_last_task_for_resource returns None when no tasks match resource_id
|
# @TEST: _get_last_task_for_resource returns None when no tasks match resource_id
|
||||||
# @PRE: tasks list has no matching resource_id
|
# @PRE: tasks list has no matching resource_id
|
||||||
# @POST: Returns None
|
# @POST: Returns None
|
||||||
@@ -266,18 +274,22 @@ def test_get_last_task_for_resource_no_match():
|
|||||||
|
|
||||||
result = service._get_last_task_for_resource("dashboard-1", [task])
|
result = service._get_last_task_for_resource("dashboard-1", [task])
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
# [/DEF:test_get_last_task_for_resource_no_match:Function]
|
# [/DEF:test_get_last_task_for_resource_no_match:Function]
|
||||||
|
|
||||||
|
|
||||||
# [DEF:test_get_dashboards_with_status_handles_mixed_naive_and_aware_task_datetimes:Function]
|
# [DEF:test_get_dashboards_with_status_handles_mixed_naive_and_aware_task_datetimes:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[TestResourceService]
|
||||||
# @TEST: get_dashboards_with_status handles mixed naive/aware datetimes without comparison errors.
|
# @TEST: get_dashboards_with_status handles mixed naive/aware datetimes without comparison errors.
|
||||||
# @PRE: Task list includes both timezone-aware and timezone-naive timestamps.
|
# @PRE: Task list includes both timezone-aware and timezone-naive timestamps.
|
||||||
# @POST: Latest task is selected deterministically and no exception is raised.
|
# @POST: Latest task is selected deterministically and no exception is raised.
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_dashboards_with_status_handles_mixed_naive_and_aware_task_datetimes():
|
async def test_get_dashboards_with_status_handles_mixed_naive_and_aware_task_datetimes():
|
||||||
with patch("src.services.resource_service.SupersetClient") as mock_client, \
|
with (
|
||||||
patch("src.services.resource_service.GitService"):
|
patch("src.services.resource_service.SupersetClient") as mock_client,
|
||||||
|
patch("src.services.resource_service.GitService"),
|
||||||
|
):
|
||||||
from src.services.resource_service import ResourceService
|
from src.services.resource_service import ResourceService
|
||||||
|
|
||||||
service = ResourceService()
|
service = ResourceService()
|
||||||
@@ -305,18 +317,22 @@ async def test_get_dashboards_with_status_handles_mixed_naive_and_aware_task_dat
|
|||||||
result = await service.get_dashboards_with_status(env, [task_naive, task_aware])
|
result = await service.get_dashboards_with_status(env, [task_naive, task_aware])
|
||||||
|
|
||||||
assert result[0]["last_task"]["task_id"] == "task-aware"
|
assert result[0]["last_task"]["task_id"] == "task-aware"
|
||||||
|
|
||||||
|
|
||||||
# [/DEF:test_get_dashboards_with_status_handles_mixed_naive_and_aware_task_datetimes:Function]
|
# [/DEF:test_get_dashboards_with_status_handles_mixed_naive_and_aware_task_datetimes:Function]
|
||||||
|
|
||||||
|
|
||||||
# [DEF:test_get_dashboards_with_status_prefers_latest_decisive_validation_status_over_newer_unknown:Function]
|
# [DEF:test_get_dashboards_with_status_prefers_latest_decisive_validation_status_over_newer_unknown:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[TestResourceService]
|
||||||
# @TEST: get_dashboards_with_status keeps latest task identity while falling back to older decisive validation status.
|
# @TEST: get_dashboards_with_status keeps latest task identity while falling back to older decisive validation status.
|
||||||
# @PRE: Same dashboard has older WARN and newer UNKNOWN validation tasks.
|
# @PRE: Same dashboard has older WARN and newer UNKNOWN validation tasks.
|
||||||
# @POST: Returned last_task points to newest task but preserves WARN as last meaningful validation state.
|
# @POST: Returned last_task points to newest task but preserves WARN as last meaningful validation state.
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_get_dashboards_with_status_prefers_latest_decisive_validation_status_over_newer_unknown():
|
async def test_get_dashboards_with_status_prefers_latest_decisive_validation_status_over_newer_unknown():
|
||||||
with patch("src.services.resource_service.SupersetClient") as mock_client, \
|
with (
|
||||||
patch("src.services.resource_service.GitService"):
|
patch("src.services.resource_service.SupersetClient") as mock_client,
|
||||||
|
patch("src.services.resource_service.GitService"),
|
||||||
|
):
|
||||||
from src.services.resource_service import ResourceService
|
from src.services.resource_service import ResourceService
|
||||||
|
|
||||||
service = ResourceService()
|
service = ResourceService()
|
||||||
@@ -343,23 +359,29 @@ async def test_get_dashboards_with_status_prefers_latest_decisive_validation_sta
|
|||||||
env = MagicMock()
|
env = MagicMock()
|
||||||
env.id = "prod"
|
env.id = "prod"
|
||||||
|
|
||||||
result = await service.get_dashboards_with_status(env, [task_warn, task_unknown])
|
result = await service.get_dashboards_with_status(
|
||||||
|
env, [task_warn, task_unknown]
|
||||||
|
)
|
||||||
|
|
||||||
assert result[0]["last_task"]["task_id"] == "task-unknown"
|
assert result[0]["last_task"]["task_id"] == "task-unknown"
|
||||||
assert result[0]["last_task"]["status"] == "RUNNING"
|
assert result[0]["last_task"]["status"] == "RUNNING"
|
||||||
assert result[0]["last_task"]["validation_status"] == "WARN"
|
assert result[0]["last_task"]["validation_status"] == "WARN"
|
||||||
|
|
||||||
|
|
||||||
# [/DEF:test_get_dashboards_with_status_prefers_latest_decisive_validation_status_over_newer_unknown:Function]
|
# [/DEF:test_get_dashboards_with_status_prefers_latest_decisive_validation_status_over_newer_unknown:Function]
|
||||||
|
|
||||||
|
|
||||||
# [DEF:test_get_dashboards_with_status_falls_back_to_latest_unknown_without_decisive_history:Function]
|
# [DEF:test_get_dashboards_with_status_falls_back_to_latest_unknown_without_decisive_history:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[TestResourceService]
|
||||||
# @TEST: get_dashboards_with_status still returns newest UNKNOWN when no decisive validation exists.
|
# @TEST: get_dashboards_with_status still returns newest UNKNOWN when no decisive validation exists.
|
||||||
# @PRE: Same dashboard has only UNKNOWN validation tasks.
|
# @PRE: Same dashboard has only UNKNOWN validation tasks.
|
||||||
# @POST: Returned last_task keeps newest UNKNOWN task.
|
# @POST: Returned last_task keeps newest UNKNOWN task.
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_get_dashboards_with_status_falls_back_to_latest_unknown_without_decisive_history():
|
async def test_get_dashboards_with_status_falls_back_to_latest_unknown_without_decisive_history():
|
||||||
with patch("src.services.resource_service.SupersetClient") as mock_client, \
|
with (
|
||||||
patch("src.services.resource_service.GitService"):
|
patch("src.services.resource_service.SupersetClient") as mock_client,
|
||||||
|
patch("src.services.resource_service.GitService"),
|
||||||
|
):
|
||||||
from src.services.resource_service import ResourceService
|
from src.services.resource_service import ResourceService
|
||||||
|
|
||||||
service = ResourceService()
|
service = ResourceService()
|
||||||
@@ -386,14 +408,19 @@ async def test_get_dashboards_with_status_falls_back_to_latest_unknown_without_d
|
|||||||
env = MagicMock()
|
env = MagicMock()
|
||||||
env.id = "prod"
|
env.id = "prod"
|
||||||
|
|
||||||
result = await service.get_dashboards_with_status(env, [task_unknown_old, task_unknown_new])
|
result = await service.get_dashboards_with_status(
|
||||||
|
env, [task_unknown_old, task_unknown_new]
|
||||||
|
)
|
||||||
|
|
||||||
assert result[0]["last_task"]["task_id"] == "task-unknown-new"
|
assert result[0]["last_task"]["task_id"] == "task-unknown-new"
|
||||||
assert result[0]["last_task"]["validation_status"] == "UNKNOWN"
|
assert result[0]["last_task"]["validation_status"] == "UNKNOWN"
|
||||||
|
|
||||||
|
|
||||||
# [/DEF:test_get_dashboards_with_status_falls_back_to_latest_unknown_without_decisive_history:Function]
|
# [/DEF:test_get_dashboards_with_status_falls_back_to_latest_unknown_without_decisive_history:Function]
|
||||||
|
|
||||||
|
|
||||||
# [DEF:test_get_last_task_for_resource_handles_mixed_naive_and_aware_created_at:Function]
|
# [DEF:test_get_last_task_for_resource_handles_mixed_naive_and_aware_created_at:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[TestResourceService]
|
||||||
# @TEST: _get_last_task_for_resource handles mixed naive/aware created_at values.
|
# @TEST: _get_last_task_for_resource handles mixed naive/aware created_at values.
|
||||||
# @PRE: Matching tasks include naive and aware created_at timestamps.
|
# @PRE: Matching tasks include naive and aware created_at timestamps.
|
||||||
# @POST: Latest task is returned without raising datetime comparison errors.
|
# @POST: Latest task is returned without raising datetime comparison errors.
|
||||||
@@ -414,11 +441,15 @@ def test_get_last_task_for_resource_handles_mixed_naive_and_aware_created_at():
|
|||||||
task_aware.params = {"resource_id": "dashboard-1"}
|
task_aware.params = {"resource_id": "dashboard-1"}
|
||||||
task_aware.created_at = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
task_aware.created_at = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
result = service._get_last_task_for_resource("dashboard-1", [task_naive, task_aware])
|
result = service._get_last_task_for_resource(
|
||||||
|
"dashboard-1", [task_naive, task_aware]
|
||||||
|
)
|
||||||
|
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert result["task_id"] == "task-new"
|
assert result["task_id"] == "task-new"
|
||||||
|
|
||||||
|
|
||||||
# [/DEF:test_get_last_task_for_resource_handles_mixed_naive_and_aware_created_at:Function]
|
# [/DEF:test_get_last_task_for_resource_handles_mixed_naive_and_aware_created_at:Function]
|
||||||
|
|
||||||
|
|
||||||
# [/DEF:backend.src.services.__tests__.test_resource_service:Module]
|
# [/DEF:TestResourceService:Module]
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
# [DEF:backend.tests.core.test_mapping_service:Module]
|
# [DEF:TestMappingService:Module]
|
||||||
#
|
#
|
||||||
# @COMPLEXITY: 3
|
# @COMPLEXITY: 3
|
||||||
# @PURPOSE: Unit tests for the IdMappingService matching UUIDs to integer IDs.
|
# @PURPOSE: Unit tests for the IdMappingService matching UUIDs to integer IDs.
|
||||||
# @LAYER: Domain
|
# @LAYER: Domain
|
||||||
# @RELATION: VERIFIES -> backend.src.core.mapping_service
|
# @RELATION: VERIFIES ->[src.core.mapping_service.IdMappingService]
|
||||||
#
|
#
|
||||||
import pytest
|
import pytest
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
@@ -21,16 +21,18 @@ if backend_dir not in sys.path:
|
|||||||
from src.models.mapping import Base, ResourceMapping, ResourceType
|
from src.models.mapping import Base, ResourceMapping, ResourceType
|
||||||
from src.core.mapping_service import IdMappingService
|
from src.core.mapping_service import IdMappingService
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def db_session():
|
def db_session():
|
||||||
# In-memory SQLite for testing
|
# In-memory SQLite for testing
|
||||||
engine = create_engine('sqlite:///:memory:')
|
engine = create_engine("sqlite:///:memory:")
|
||||||
Base.metadata.create_all(engine)
|
Base.metadata.create_all(engine)
|
||||||
Session = sessionmaker(bind=engine)
|
Session = sessionmaker(bind=engine)
|
||||||
session = Session()
|
session = Session()
|
||||||
yield session
|
yield session
|
||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
class MockSupersetClient:
|
class MockSupersetClient:
|
||||||
def __init__(self, resources):
|
def __init__(self, resources):
|
||||||
self.resources = resources
|
self.resources = resources
|
||||||
@@ -38,13 +40,22 @@ class MockSupersetClient:
|
|||||||
def get_all_resources(self, endpoint, since_dttm=None):
|
def get_all_resources(self, endpoint, since_dttm=None):
|
||||||
return self.resources.get(endpoint, [])
|
return self.resources.get(endpoint, [])
|
||||||
|
|
||||||
|
|
||||||
|
# [DEF:test_sync_environment_upserts_correctly:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[TestMappingService]
|
||||||
def test_sync_environment_upserts_correctly(db_session):
|
def test_sync_environment_upserts_correctly(db_session):
|
||||||
service = IdMappingService(db_session)
|
service = IdMappingService(db_session)
|
||||||
mock_client = MockSupersetClient({
|
mock_client = MockSupersetClient(
|
||||||
|
{
|
||||||
"chart": [
|
"chart": [
|
||||||
{"id": 42, "uuid": "123e4567-e89b-12d3-a456-426614174000", "slice_name": "Test Chart"}
|
{
|
||||||
|
"id": 42,
|
||||||
|
"uuid": "123e4567-e89b-12d3-a456-426614174000",
|
||||||
|
"slice_name": "Test Chart",
|
||||||
|
}
|
||||||
]
|
]
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
service.sync_environment("test-env", mock_client)
|
service.sync_environment("test-env", mock_client)
|
||||||
|
|
||||||
@@ -56,6 +67,12 @@ def test_sync_environment_upserts_correctly(db_session):
|
|||||||
assert mapping.remote_integer_id == "42"
|
assert mapping.remote_integer_id == "42"
|
||||||
assert mapping.resource_name == "Test Chart"
|
assert mapping.resource_name == "Test Chart"
|
||||||
|
|
||||||
|
|
||||||
|
# [/DEF:test_sync_environment_upserts_correctly:Function]
|
||||||
|
|
||||||
|
|
||||||
|
# [DEF:test_get_remote_id_returns_integer:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[TestMappingService]
|
||||||
def test_get_remote_id_returns_integer(db_session):
|
def test_get_remote_id_returns_integer(db_session):
|
||||||
service = IdMappingService(db_session)
|
service = IdMappingService(db_session)
|
||||||
mapping = ResourceMapping(
|
mapping = ResourceMapping(
|
||||||
@@ -64,7 +81,7 @@ def test_get_remote_id_returns_integer(db_session):
|
|||||||
uuid="uuid-1",
|
uuid="uuid-1",
|
||||||
remote_integer_id="99",
|
remote_integer_id="99",
|
||||||
resource_name="Test DS",
|
resource_name="Test DS",
|
||||||
last_synced_at=datetime.now(timezone.utc)
|
last_synced_at=datetime.now(timezone.utc),
|
||||||
)
|
)
|
||||||
db_session.add(mapping)
|
db_session.add(mapping)
|
||||||
db_session.commit()
|
db_session.commit()
|
||||||
@@ -72,80 +89,126 @@ def test_get_remote_id_returns_integer(db_session):
|
|||||||
result = service.get_remote_id("test-env", ResourceType.DATASET, "uuid-1")
|
result = service.get_remote_id("test-env", ResourceType.DATASET, "uuid-1")
|
||||||
assert result == 99
|
assert result == 99
|
||||||
|
|
||||||
|
|
||||||
|
# [/DEF:test_get_remote_id_returns_integer:Function]
|
||||||
|
|
||||||
|
|
||||||
|
# [DEF:test_get_remote_ids_batch_returns_dict:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[TestMappingService]
|
||||||
def test_get_remote_ids_batch_returns_dict(db_session):
|
def test_get_remote_ids_batch_returns_dict(db_session):
|
||||||
service = IdMappingService(db_session)
|
service = IdMappingService(db_session)
|
||||||
m1 = ResourceMapping(
|
m1 = ResourceMapping(
|
||||||
environment_id="test-env",
|
environment_id="test-env",
|
||||||
resource_type=ResourceType.DASHBOARD,
|
resource_type=ResourceType.DASHBOARD,
|
||||||
uuid="uuid-1",
|
uuid="uuid-1",
|
||||||
remote_integer_id="11"
|
remote_integer_id="11",
|
||||||
)
|
)
|
||||||
m2 = ResourceMapping(
|
m2 = ResourceMapping(
|
||||||
environment_id="test-env",
|
environment_id="test-env",
|
||||||
resource_type=ResourceType.DASHBOARD,
|
resource_type=ResourceType.DASHBOARD,
|
||||||
uuid="uuid-2",
|
uuid="uuid-2",
|
||||||
remote_integer_id="22"
|
remote_integer_id="22",
|
||||||
)
|
)
|
||||||
db_session.add_all([m1, m2])
|
db_session.add_all([m1, m2])
|
||||||
db_session.commit()
|
db_session.commit()
|
||||||
|
|
||||||
result = service.get_remote_ids_batch("test-env", ResourceType.DASHBOARD, ["uuid-1", "uuid-2", "uuid-missing"])
|
result = service.get_remote_ids_batch(
|
||||||
|
"test-env", ResourceType.DASHBOARD, ["uuid-1", "uuid-2", "uuid-missing"]
|
||||||
|
)
|
||||||
|
|
||||||
assert len(result) == 2
|
assert len(result) == 2
|
||||||
assert result["uuid-1"] == 11
|
assert result["uuid-1"] == 11
|
||||||
assert result["uuid-2"] == 22
|
assert result["uuid-2"] == 22
|
||||||
assert "uuid-missing" not in result
|
assert "uuid-missing" not in result
|
||||||
|
|
||||||
|
|
||||||
|
# [/DEF:test_get_remote_ids_batch_returns_dict:Function]
|
||||||
|
|
||||||
|
|
||||||
|
# [DEF:test_sync_environment_updates_existing_mapping:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[TestMappingService]
|
||||||
def test_sync_environment_updates_existing_mapping(db_session):
|
def test_sync_environment_updates_existing_mapping(db_session):
|
||||||
"""Verify that sync_environment updates an existing mapping (upsert UPDATE path)."""
|
"""Verify that sync_environment updates an existing mapping (upsert UPDATE path)."""
|
||||||
from src.models.mapping import ResourceMapping
|
from src.models.mapping import ResourceMapping
|
||||||
|
|
||||||
# Pre-populate a mapping
|
# Pre-populate a mapping
|
||||||
existing = ResourceMapping(
|
existing = ResourceMapping(
|
||||||
environment_id="test-env",
|
environment_id="test-env",
|
||||||
resource_type=ResourceType.CHART,
|
resource_type=ResourceType.CHART,
|
||||||
uuid="123e4567-e89b-12d3-a456-426614174000",
|
uuid="123e4567-e89b-12d3-a456-426614174000",
|
||||||
remote_integer_id="10",
|
remote_integer_id="10",
|
||||||
resource_name="Old Name"
|
resource_name="Old Name",
|
||||||
)
|
)
|
||||||
db_session.add(existing)
|
db_session.add(existing)
|
||||||
db_session.commit()
|
db_session.commit()
|
||||||
|
|
||||||
service = IdMappingService(db_session)
|
service = IdMappingService(db_session)
|
||||||
mock_client = MockSupersetClient({
|
mock_client = MockSupersetClient(
|
||||||
|
{
|
||||||
"chart": [
|
"chart": [
|
||||||
{"id": 42, "uuid": "123e4567-e89b-12d3-a456-426614174000", "slice_name": "Updated Name"}
|
{
|
||||||
|
"id": 42,
|
||||||
|
"uuid": "123e4567-e89b-12d3-a456-426614174000",
|
||||||
|
"slice_name": "Updated Name",
|
||||||
|
}
|
||||||
]
|
]
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
service.sync_environment("test-env", mock_client)
|
service.sync_environment("test-env", mock_client)
|
||||||
|
|
||||||
mapping = db_session.query(ResourceMapping).filter_by(
|
mapping = (
|
||||||
uuid="123e4567-e89b-12d3-a456-426614174000"
|
db_session.query(ResourceMapping)
|
||||||
).first()
|
.filter_by(uuid="123e4567-e89b-12d3-a456-426614174000")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
assert mapping.remote_integer_id == "42"
|
assert mapping.remote_integer_id == "42"
|
||||||
assert mapping.resource_name == "Updated Name"
|
assert mapping.resource_name == "Updated Name"
|
||||||
# Should still be only one record (updated, not duplicated)
|
# Should still be only one record (updated, not duplicated)
|
||||||
count = db_session.query(ResourceMapping).count()
|
count = db_session.query(ResourceMapping).count()
|
||||||
assert count == 1
|
assert count == 1
|
||||||
|
|
||||||
|
|
||||||
|
# [/DEF:test_sync_environment_updates_existing_mapping:Function]
|
||||||
|
|
||||||
|
|
||||||
|
# [DEF:test_sync_environment_skips_resources_without_uuid:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[TestMappingService]
|
||||||
def test_sync_environment_skips_resources_without_uuid(db_session):
|
def test_sync_environment_skips_resources_without_uuid(db_session):
|
||||||
"""Resources missing uuid or having id=None should be silently skipped."""
|
"""Resources missing uuid or having id=None should be silently skipped."""
|
||||||
service = IdMappingService(db_session)
|
service = IdMappingService(db_session)
|
||||||
mock_client = MockSupersetClient({
|
mock_client = MockSupersetClient(
|
||||||
|
{
|
||||||
"chart": [
|
"chart": [
|
||||||
{"id": 42, "slice_name": "No UUID"}, # Missing 'uuid' -> skipped
|
{"id": 42, "slice_name": "No UUID"}, # Missing 'uuid' -> skipped
|
||||||
{"id": None, "uuid": "valid-uuid", "slice_name": "ID is None"}, # id=None -> skipped
|
{
|
||||||
{"id": None, "uuid": None, "slice_name": "Both None"}, # both None -> skipped
|
"id": None,
|
||||||
|
"uuid": "valid-uuid",
|
||||||
|
"slice_name": "ID is None",
|
||||||
|
}, # id=None -> skipped
|
||||||
|
{
|
||||||
|
"id": None,
|
||||||
|
"uuid": None,
|
||||||
|
"slice_name": "Both None",
|
||||||
|
}, # both None -> skipped
|
||||||
]
|
]
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
service.sync_environment("test-env", mock_client)
|
service.sync_environment("test-env", mock_client)
|
||||||
|
|
||||||
count = db_session.query(ResourceMapping).count()
|
count = db_session.query(ResourceMapping).count()
|
||||||
assert count == 0
|
assert count == 0
|
||||||
|
|
||||||
|
|
||||||
|
# [/DEF:test_sync_environment_skips_resources_without_uuid:Function]
|
||||||
|
|
||||||
|
|
||||||
|
# [DEF:test_sync_environment_handles_api_error_gracefully:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[TestMappingService]
|
||||||
def test_sync_environment_handles_api_error_gracefully(db_session):
|
def test_sync_environment_handles_api_error_gracefully(db_session):
|
||||||
"""If one resource type fails, others should still sync."""
|
"""If one resource type fails, others should still sync."""
|
||||||
|
|
||||||
class FailingClient:
|
class FailingClient:
|
||||||
def get_all_resources(self, endpoint, since_dttm=None):
|
def get_all_resources(self, endpoint, since_dttm=None):
|
||||||
if endpoint == "chart":
|
if endpoint == "chart":
|
||||||
@@ -162,12 +225,24 @@ def test_sync_environment_handles_api_error_gracefully(db_session):
|
|||||||
mapping = db_session.query(ResourceMapping).first()
|
mapping = db_session.query(ResourceMapping).first()
|
||||||
assert mapping.resource_type == ResourceType.DATASET
|
assert mapping.resource_type == ResourceType.DATASET
|
||||||
|
|
||||||
|
|
||||||
|
# [/DEF:test_sync_environment_handles_api_error_gracefully:Function]
|
||||||
|
|
||||||
|
|
||||||
|
# [DEF:test_get_remote_id_returns_none_for_missing:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[TestMappingService]
|
||||||
def test_get_remote_id_returns_none_for_missing(db_session):
|
def test_get_remote_id_returns_none_for_missing(db_session):
|
||||||
"""get_remote_id should return None when no mapping exists."""
|
"""get_remote_id should return None when no mapping exists."""
|
||||||
service = IdMappingService(db_session)
|
service = IdMappingService(db_session)
|
||||||
result = service.get_remote_id("test-env", ResourceType.CHART, "nonexistent-uuid")
|
result = service.get_remote_id("test-env", ResourceType.CHART, "nonexistent-uuid")
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
# [/DEF:test_get_remote_id_returns_none_for_missing:Function]
|
||||||
|
|
||||||
|
|
||||||
|
# [DEF:test_get_remote_ids_batch_returns_empty_for_empty_input:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[TestMappingService]
|
||||||
def test_get_remote_ids_batch_returns_empty_for_empty_input(db_session):
|
def test_get_remote_ids_batch_returns_empty_for_empty_input(db_session):
|
||||||
"""get_remote_ids_batch should return {} for an empty list of UUIDs."""
|
"""get_remote_ids_batch should return {} for an empty list of UUIDs."""
|
||||||
service = IdMappingService(db_session)
|
service = IdMappingService(db_session)
|
||||||
@@ -175,14 +250,19 @@ def test_get_remote_ids_batch_returns_empty_for_empty_input(db_session):
|
|||||||
assert result == {}
|
assert result == {}
|
||||||
|
|
||||||
|
|
||||||
|
# [/DEF:test_get_remote_ids_batch_returns_empty_for_empty_input:Function]
|
||||||
|
|
||||||
|
|
||||||
|
# [DEF:test_mapping_service_alignment_with_test_data:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[TestMappingService]
|
||||||
def test_mapping_service_alignment_with_test_data(db_session):
|
def test_mapping_service_alignment_with_test_data(db_session):
|
||||||
"""**@TEST_DATA**: Verifies that the service aligns with the resource_mapping_record contract."""
|
"""**@TEST_DATA**: Verifies that the service aligns with the resource_mapping_record contract."""
|
||||||
# Contract: {'environment_id': 'prod-env-1', 'resource_type': 'chart', 'uuid': '123e4567-e89b-12d3-a456-426614174000', 'remote_integer_id': '42'}
|
# Contract: {'environment_id': 'prod-env-1', 'resource_type': 'chart', 'uuid': '123e4567-e89b-12d3-a456-426614174000', 'remote_integer_id': '42'}
|
||||||
contract_data = {
|
contract_data = {
|
||||||
'environment_id': 'prod-env-1',
|
"environment_id": "prod-env-1",
|
||||||
'resource_type': ResourceType.CHART,
|
"resource_type": ResourceType.CHART,
|
||||||
'uuid': '123e4567-e89b-12d3-a456-426614174000',
|
"uuid": "123e4567-e89b-12d3-a456-426614174000",
|
||||||
'remote_integer_id': '42'
|
"remote_integer_id": "42",
|
||||||
}
|
}
|
||||||
|
|
||||||
mapping = ResourceMapping(**contract_data)
|
mapping = ResourceMapping(**contract_data)
|
||||||
@@ -191,14 +271,19 @@ def test_mapping_service_alignment_with_test_data(db_session):
|
|||||||
|
|
||||||
service = IdMappingService(db_session)
|
service = IdMappingService(db_session)
|
||||||
result = service.get_remote_id(
|
result = service.get_remote_id(
|
||||||
contract_data['environment_id'],
|
contract_data["environment_id"],
|
||||||
contract_data['resource_type'],
|
contract_data["resource_type"],
|
||||||
contract_data['uuid']
|
contract_data["uuid"],
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result == 42
|
assert result == 42
|
||||||
|
|
||||||
|
|
||||||
|
# [/DEF:test_mapping_service_alignment_with_test_data:Function]
|
||||||
|
|
||||||
|
|
||||||
|
# [DEF:test_sync_environment_requires_existing_env:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[TestMappingService]
|
||||||
def test_sync_environment_requires_existing_env(db_session):
|
def test_sync_environment_requires_existing_env(db_session):
|
||||||
"""**@PRE**: Verify behavior when environment_id is invalid/missing in DB.
|
"""**@PRE**: Verify behavior when environment_id is invalid/missing in DB.
|
||||||
Note: The current implementation doesn't strictly check for environment existencia in the DB
|
Note: The current implementation doesn't strictly check for environment existencia in the DB
|
||||||
@@ -217,28 +302,38 @@ def test_sync_environment_requires_existing_env(db_session):
|
|||||||
assert db_session.query(ResourceMapping).count() == 0
|
assert db_session.query(ResourceMapping).count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
# [/DEF:test_sync_environment_requires_existing_env:Function]
|
||||||
|
|
||||||
|
|
||||||
|
# [DEF:test_sync_environment_deletes_stale_mappings:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[TestMappingService]
|
||||||
def test_sync_environment_deletes_stale_mappings(db_session):
|
def test_sync_environment_deletes_stale_mappings(db_session):
|
||||||
"""Verify that mappings for resources deleted from the remote environment
|
"""Verify that mappings for resources deleted from the remote environment
|
||||||
are removed from the local DB on the next sync cycle."""
|
are removed from the local DB on the next sync cycle."""
|
||||||
service = IdMappingService(db_session)
|
service = IdMappingService(db_session)
|
||||||
|
|
||||||
# First sync: 2 charts exist
|
# First sync: 2 charts exist
|
||||||
client_v1 = MockSupersetClient({
|
client_v1 = MockSupersetClient(
|
||||||
|
{
|
||||||
"chart": [
|
"chart": [
|
||||||
{"id": 1, "uuid": "aaa", "slice_name": "Chart A"},
|
{"id": 1, "uuid": "aaa", "slice_name": "Chart A"},
|
||||||
{"id": 2, "uuid": "bbb", "slice_name": "Chart B"},
|
{"id": 2, "uuid": "bbb", "slice_name": "Chart B"},
|
||||||
]
|
]
|
||||||
})
|
}
|
||||||
|
)
|
||||||
service.sync_environment("env1", client_v1)
|
service.sync_environment("env1", client_v1)
|
||||||
assert db_session.query(ResourceMapping).filter_by(environment_id="env1").count() == 2
|
assert (
|
||||||
|
db_session.query(ResourceMapping).filter_by(environment_id="env1").count() == 2
|
||||||
|
)
|
||||||
|
|
||||||
# Second sync: user deleted Chart B from superset
|
# Second sync: user deleted Chart B from superset
|
||||||
client_v2 = MockSupersetClient({
|
client_v2 = MockSupersetClient(
|
||||||
|
{
|
||||||
"chart": [
|
"chart": [
|
||||||
{"id": 1, "uuid": "aaa", "slice_name": "Chart A"},
|
{"id": 1, "uuid": "aaa", "slice_name": "Chart A"},
|
||||||
]
|
]
|
||||||
})
|
}
|
||||||
|
)
|
||||||
service.sync_environment("env1", client_v2)
|
service.sync_environment("env1", client_v2)
|
||||||
|
|
||||||
remaining = db_session.query(ResourceMapping).filter_by(environment_id="env1").all()
|
remaining = db_session.query(ResourceMapping).filter_by(environment_id="env1").all()
|
||||||
@@ -246,4 +341,5 @@ def test_sync_environment_deletes_stale_mappings(db_session):
|
|||||||
assert remaining[0].uuid == "aaa"
|
assert remaining[0].uuid == "aaa"
|
||||||
|
|
||||||
|
|
||||||
# [/DEF:backend.tests.core.test_mapping_service:Module]
|
# [/DEF:test_sync_environment_deletes_stale_mappings:Function]
|
||||||
|
# [/DEF:TestMappingService:Module]
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
# [DEF:backend.tests.test_dashboards_api:Module]
|
# [DEF:TestDashboardsApi:Module]
|
||||||
|
# @RELATION: VERIFIES ->[src.api.routes.dashboards]
|
||||||
# @COMPLEXITY: 3
|
# @COMPLEXITY: 3
|
||||||
# @PURPOSE: Comprehensive contract-driven tests for Dashboard Hub API
|
# @PURPOSE: Comprehensive contract-driven tests for Dashboard Hub API
|
||||||
# @LAYER: Domain (Tests)
|
# @LAYER: Domain (Tests)
|
||||||
@@ -8,8 +9,20 @@ from fastapi.testclient import TestClient
|
|||||||
from unittest.mock import MagicMock, patch, AsyncMock
|
from unittest.mock import MagicMock, patch, AsyncMock
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from src.app import app
|
from src.app import app
|
||||||
from src.api.routes.dashboards import DashboardsResponse, DashboardDetailResponse, DashboardTaskHistoryResponse, DatabaseMappingsResponse
|
from src.api.routes.dashboards import (
|
||||||
from src.dependencies import get_current_user, has_permission, get_config_manager, get_task_manager, get_resource_service, get_mapping_service
|
DashboardsResponse,
|
||||||
|
DashboardDetailResponse,
|
||||||
|
DashboardTaskHistoryResponse,
|
||||||
|
DatabaseMappingsResponse,
|
||||||
|
)
|
||||||
|
from src.dependencies import (
|
||||||
|
get_current_user,
|
||||||
|
has_permission,
|
||||||
|
get_config_manager,
|
||||||
|
get_task_manager,
|
||||||
|
get_resource_service,
|
||||||
|
get_mapping_service,
|
||||||
|
)
|
||||||
|
|
||||||
# Global mock user
|
# Global mock user
|
||||||
mock_user = MagicMock()
|
mock_user = MagicMock()
|
||||||
@@ -19,6 +32,7 @@ admin_role = MagicMock()
|
|||||||
admin_role.name = "Admin"
|
admin_role.name = "Admin"
|
||||||
mock_user.roles.append(admin_role)
|
mock_user.roles.append(admin_role)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def mock_deps():
|
def mock_deps():
|
||||||
config_manager = MagicMock()
|
config_manager = MagicMock()
|
||||||
@@ -33,9 +47,15 @@ def mock_deps():
|
|||||||
app.dependency_overrides[get_current_user] = lambda: mock_user
|
app.dependency_overrides[get_current_user] = lambda: mock_user
|
||||||
|
|
||||||
# Overrides for specific permission checks
|
# Overrides for specific permission checks
|
||||||
app.dependency_overrides[has_permission("plugin:migration", "READ")] = lambda: mock_user
|
app.dependency_overrides[has_permission("plugin:migration", "READ")] = (
|
||||||
app.dependency_overrides[has_permission("plugin:migration", "EXECUTE")] = lambda: mock_user
|
lambda: mock_user
|
||||||
app.dependency_overrides[has_permission("plugin:backup", "EXECUTE")] = lambda: mock_user
|
)
|
||||||
|
app.dependency_overrides[has_permission("plugin:migration", "EXECUTE")] = (
|
||||||
|
lambda: mock_user
|
||||||
|
)
|
||||||
|
app.dependency_overrides[has_permission("plugin:backup", "EXECUTE")] = (
|
||||||
|
lambda: mock_user
|
||||||
|
)
|
||||||
app.dependency_overrides[has_permission("tasks", "READ")] = lambda: mock_user
|
app.dependency_overrides[has_permission("tasks", "READ")] = lambda: mock_user
|
||||||
app.dependency_overrides[has_permission("dashboards", "READ")] = lambda: mock_user
|
app.dependency_overrides[has_permission("dashboards", "READ")] = lambda: mock_user
|
||||||
|
|
||||||
@@ -43,14 +63,18 @@ def mock_deps():
|
|||||||
"config": config_manager,
|
"config": config_manager,
|
||||||
"task": task_manager,
|
"task": task_manager,
|
||||||
"resource": resource_service,
|
"resource": resource_service,
|
||||||
"mapping": mapping_service
|
"mapping": mapping_service,
|
||||||
}
|
}
|
||||||
app.dependency_overrides.clear()
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
|
|
||||||
# --- 1. get_dashboards tests ---
|
# --- 1. get_dashboards tests ---
|
||||||
|
|
||||||
|
|
||||||
|
# [DEF:test_get_dashboards_success:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[TestDashboardsApi]
|
||||||
def test_get_dashboards_success(mock_deps):
|
def test_get_dashboards_success(mock_deps):
|
||||||
"""Uses @TEST_FIXTURE: dashboard_list_happy data."""
|
"""Uses @TEST_FIXTURE: dashboard_list_happy data."""
|
||||||
mock_env = MagicMock()
|
mock_env = MagicMock()
|
||||||
@@ -59,9 +83,16 @@ def test_get_dashboards_success(mock_deps):
|
|||||||
mock_deps["task"].get_all_tasks.return_value = []
|
mock_deps["task"].get_all_tasks.return_value = []
|
||||||
|
|
||||||
# @TEST_FIXTURE: dashboard_list_happy -> {"id": 1, "title": "Main Revenue"}
|
# @TEST_FIXTURE: dashboard_list_happy -> {"id": 1, "title": "Main Revenue"}
|
||||||
mock_deps["resource"].get_dashboards_with_status = AsyncMock(return_value=[
|
mock_deps["resource"].get_dashboards_with_status = AsyncMock(
|
||||||
{"id": 1, "title": "Main Revenue", "slug": "main-revenue", "git_status": {"branch": "main", "sync_status": "OK"}}
|
return_value=[
|
||||||
])
|
{
|
||||||
|
"id": 1,
|
||||||
|
"title": "Main Revenue",
|
||||||
|
"slug": "main-revenue",
|
||||||
|
"git_status": {"branch": "main", "sync_status": "OK"},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
response = client.get("/api/dashboards?env_id=prod&page=1&page_size=10")
|
response = client.get("/api/dashboards?env_id=prod&page=1&page_size=10")
|
||||||
|
|
||||||
@@ -80,15 +111,23 @@ def test_get_dashboards_success(mock_deps):
|
|||||||
# schema validation
|
# schema validation
|
||||||
DashboardsResponse(**data)
|
DashboardsResponse(**data)
|
||||||
|
|
||||||
|
|
||||||
|
# [/DEF:test_get_dashboards_success:Function]
|
||||||
|
|
||||||
|
|
||||||
|
# [DEF:test_get_dashboards_with_search:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[TestDashboardsApi]
|
||||||
def test_get_dashboards_with_search(mock_deps):
|
def test_get_dashboards_with_search(mock_deps):
|
||||||
mock_env = MagicMock()
|
mock_env = MagicMock()
|
||||||
mock_env.id = "prod"
|
mock_env.id = "prod"
|
||||||
mock_deps["config"].get_environments.return_value = [mock_env]
|
mock_deps["config"].get_environments.return_value = [mock_env]
|
||||||
mock_deps["task"].get_all_tasks.return_value = []
|
mock_deps["task"].get_all_tasks.return_value = []
|
||||||
mock_deps["resource"].get_dashboards_with_status = AsyncMock(return_value=[
|
mock_deps["resource"].get_dashboards_with_status = AsyncMock(
|
||||||
|
return_value=[
|
||||||
{"id": 1, "title": "Sales Report", "slug": "sales"},
|
{"id": 1, "title": "Sales Report", "slug": "sales"},
|
||||||
{"id": 2, "title": "Marketing", "slug": "marketing"}
|
{"id": 2, "title": "Marketing", "slug": "marketing"},
|
||||||
])
|
]
|
||||||
|
)
|
||||||
|
|
||||||
response = client.get("/api/dashboards?env_id=prod&search=sales")
|
response = client.get("/api/dashboards?env_id=prod&search=sales")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -96,6 +135,12 @@ def test_get_dashboards_with_search(mock_deps):
|
|||||||
assert len(data["dashboards"]) == 1
|
assert len(data["dashboards"]) == 1
|
||||||
assert data["dashboards"][0]["title"] == "Sales Report"
|
assert data["dashboards"][0]["title"] == "Sales Report"
|
||||||
|
|
||||||
|
|
||||||
|
# [/DEF:test_get_dashboards_with_search:Function]
|
||||||
|
|
||||||
|
|
||||||
|
# [DEF:test_get_dashboards_empty:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[TestDashboardsApi]
|
||||||
def test_get_dashboards_empty(mock_deps):
|
def test_get_dashboards_empty(mock_deps):
|
||||||
"""@TEST_EDGE: empty_dashboards -> {env_id: 'empty_env', expected_total: 0}"""
|
"""@TEST_EDGE: empty_dashboards -> {env_id: 'empty_env', expected_total: 0}"""
|
||||||
mock_env = MagicMock()
|
mock_env = MagicMock()
|
||||||
@@ -112,6 +157,12 @@ def test_get_dashboards_empty(mock_deps):
|
|||||||
assert data["total_pages"] == 1
|
assert data["total_pages"] == 1
|
||||||
DashboardsResponse(**data)
|
DashboardsResponse(**data)
|
||||||
|
|
||||||
|
|
||||||
|
# [/DEF:test_get_dashboards_empty:Function]
|
||||||
|
|
||||||
|
|
||||||
|
# [DEF:test_get_dashboards_superset_failure:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[TestDashboardsApi]
|
||||||
def test_get_dashboards_superset_failure(mock_deps):
|
def test_get_dashboards_superset_failure(mock_deps):
|
||||||
"""@TEST_EDGE: external_superset_failure -> {env_id: 'bad_conn', status: 503}"""
|
"""@TEST_EDGE: external_superset_failure -> {env_id: 'bad_conn', status: 503}"""
|
||||||
mock_env = MagicMock()
|
mock_env = MagicMock()
|
||||||
@@ -126,12 +177,24 @@ def test_get_dashboards_superset_failure(mock_deps):
|
|||||||
assert response.status_code == 503
|
assert response.status_code == 503
|
||||||
assert "Failed to fetch dashboards" in response.json()["detail"]
|
assert "Failed to fetch dashboards" in response.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
# [/DEF:test_get_dashboards_superset_failure:Function]
|
||||||
|
|
||||||
|
|
||||||
|
# [DEF:test_get_dashboards_env_not_found:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[TestDashboardsApi]
|
||||||
def test_get_dashboards_env_not_found(mock_deps):
|
def test_get_dashboards_env_not_found(mock_deps):
|
||||||
mock_deps["config"].get_environments.return_value = []
|
mock_deps["config"].get_environments.return_value = []
|
||||||
response = client.get("/api/dashboards?env_id=nonexistent")
|
response = client.get("/api/dashboards?env_id=nonexistent")
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
assert "Environment not found" in response.json()["detail"]
|
assert "Environment not found" in response.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
# [/DEF:test_get_dashboards_env_not_found:Function]
|
||||||
|
|
||||||
|
|
||||||
|
# [DEF:test_get_dashboards_invalid_pagination:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[TestDashboardsApi]
|
||||||
def test_get_dashboards_invalid_pagination(mock_deps):
|
def test_get_dashboards_invalid_pagination(mock_deps):
|
||||||
mock_env = MagicMock()
|
mock_env = MagicMock()
|
||||||
mock_env.id = "prod"
|
mock_env.id = "prod"
|
||||||
@@ -147,16 +210,24 @@ def test_get_dashboards_invalid_pagination(mock_deps):
|
|||||||
# page_size > 100
|
# page_size > 100
|
||||||
assert client.get("/api/dashboards?env_id=prod&page_size=101").status_code == 400
|
assert client.get("/api/dashboards?env_id=prod&page_size=101").status_code == 400
|
||||||
|
|
||||||
|
|
||||||
# --- 2. get_database_mappings tests ---
|
# --- 2. get_database_mappings tests ---
|
||||||
|
|
||||||
|
# [/DEF:test_get_dashboards_invalid_pagination:Function]
|
||||||
|
|
||||||
|
|
||||||
|
# [DEF:test_get_database_mappings_success:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[TestDashboardsApi]
|
||||||
def test_get_database_mappings_success(mock_deps):
|
def test_get_database_mappings_success(mock_deps):
|
||||||
mock_s = MagicMock(); mock_s.id = "s"
|
mock_s = MagicMock()
|
||||||
mock_t = MagicMock(); mock_t.id = "t"
|
mock_s.id = "s"
|
||||||
|
mock_t = MagicMock()
|
||||||
|
mock_t.id = "t"
|
||||||
mock_deps["config"].get_environments.return_value = [mock_s, mock_t]
|
mock_deps["config"].get_environments.return_value = [mock_s, mock_t]
|
||||||
|
|
||||||
mock_deps["mapping"].get_suggestions = AsyncMock(return_value=[
|
mock_deps["mapping"].get_suggestions = AsyncMock(
|
||||||
{"source_db": "src", "target_db": "dst", "confidence": 0.9}
|
return_value=[{"source_db": "src", "target_db": "dst", "confidence": 0.9}]
|
||||||
])
|
)
|
||||||
response = client.get("/api/dashboards/db-mappings?source_env_id=s&target_env_id=t")
|
response = client.get("/api/dashboards/db-mappings?source_env_id=s&target_env_id=t")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
data = response.json()
|
||||||
@@ -164,13 +235,27 @@ def test_get_database_mappings_success(mock_deps):
|
|||||||
assert data["mappings"][0]["confidence"] == 0.9
|
assert data["mappings"][0]["confidence"] == 0.9
|
||||||
DatabaseMappingsResponse(**data)
|
DatabaseMappingsResponse(**data)
|
||||||
|
|
||||||
|
|
||||||
|
# [/DEF:test_get_database_mappings_success:Function]
|
||||||
|
|
||||||
|
|
||||||
|
# [DEF:test_get_database_mappings_env_not_found:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[TestDashboardsApi]
|
||||||
def test_get_database_mappings_env_not_found(mock_deps):
|
def test_get_database_mappings_env_not_found(mock_deps):
|
||||||
mock_deps["config"].get_environments.return_value = []
|
mock_deps["config"].get_environments.return_value = []
|
||||||
response = client.get("/api/dashboards/db-mappings?source_env_id=ghost&target_env_id=t")
|
response = client.get(
|
||||||
|
"/api/dashboards/db-mappings?source_env_id=ghost&target_env_id=t"
|
||||||
|
)
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
# --- 3. get_dashboard_detail tests ---
|
# --- 3. get_dashboard_detail tests ---
|
||||||
|
|
||||||
|
# [/DEF:test_get_database_mappings_env_not_found:Function]
|
||||||
|
|
||||||
|
|
||||||
|
# [DEF:test_get_dashboard_detail_success:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[TestDashboardsApi]
|
||||||
def test_get_dashboard_detail_success(mock_deps):
|
def test_get_dashboard_detail_success(mock_deps):
|
||||||
with patch("src.api.routes.dashboards.SupersetClient") as mock_client_cls:
|
with patch("src.api.routes.dashboards.SupersetClient") as mock_client_cls:
|
||||||
mock_env = MagicMock()
|
mock_env = MagicMock()
|
||||||
@@ -179,8 +264,12 @@ def test_get_dashboard_detail_success(mock_deps):
|
|||||||
|
|
||||||
mock_client = MagicMock()
|
mock_client = MagicMock()
|
||||||
detail_payload = {
|
detail_payload = {
|
||||||
"id": 42, "title": "Detail", "charts": [], "datasets": [],
|
"id": 42,
|
||||||
"chart_count": 0, "dataset_count": 0
|
"title": "Detail",
|
||||||
|
"charts": [],
|
||||||
|
"datasets": [],
|
||||||
|
"chart_count": 0,
|
||||||
|
"dataset_count": 0,
|
||||||
}
|
}
|
||||||
mock_client.get_dashboard_detail.return_value = detail_payload
|
mock_client.get_dashboard_detail.return_value = detail_payload
|
||||||
mock_client_cls.return_value = mock_client
|
mock_client_cls.return_value = mock_client
|
||||||
@@ -191,16 +280,36 @@ def test_get_dashboard_detail_success(mock_deps):
|
|||||||
assert data["id"] == 42
|
assert data["id"] == 42
|
||||||
DashboardDetailResponse(**data)
|
DashboardDetailResponse(**data)
|
||||||
|
|
||||||
|
|
||||||
|
# [/DEF:test_get_dashboard_detail_success:Function]
|
||||||
|
|
||||||
|
|
||||||
|
# [DEF:test_get_dashboard_detail_env_not_found:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[TestDashboardsApi]
|
||||||
def test_get_dashboard_detail_env_not_found(mock_deps):
|
def test_get_dashboard_detail_env_not_found(mock_deps):
|
||||||
mock_deps["config"].get_environments.return_value = []
|
mock_deps["config"].get_environments.return_value = []
|
||||||
response = client.get("/api/dashboards/42?env_id=missing")
|
response = client.get("/api/dashboards/42?env_id=missing")
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
# --- 4. get_dashboard_tasks_history tests ---
|
# --- 4. get_dashboard_tasks_history tests ---
|
||||||
|
|
||||||
|
# [/DEF:test_get_dashboard_detail_env_not_found:Function]
|
||||||
|
|
||||||
|
|
||||||
|
# [DEF:test_get_dashboard_tasks_history_success:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[TestDashboardsApi]
|
||||||
def test_get_dashboard_tasks_history_success(mock_deps):
|
def test_get_dashboard_tasks_history_success(mock_deps):
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
task1 = MagicMock(id="t1", plugin_id="superset-backup", status="SUCCESS", started_at=now, finished_at=None, params={"env": "prod", "dashboards": [42]}, result={})
|
task1 = MagicMock(
|
||||||
|
id="t1",
|
||||||
|
plugin_id="superset-backup",
|
||||||
|
status="SUCCESS",
|
||||||
|
started_at=now,
|
||||||
|
finished_at=None,
|
||||||
|
params={"env": "prod", "dashboards": [42]},
|
||||||
|
result={},
|
||||||
|
)
|
||||||
mock_deps["task"].get_all_tasks.return_value = [task1]
|
mock_deps["task"].get_all_tasks.return_value = [task1]
|
||||||
|
|
||||||
response = client.get("/api/dashboards/42/tasks?env_id=prod")
|
response = client.get("/api/dashboards/42/tasks?env_id=prod")
|
||||||
@@ -210,19 +319,38 @@ def test_get_dashboard_tasks_history_success(mock_deps):
|
|||||||
assert len(data["items"]) == 1
|
assert len(data["items"]) == 1
|
||||||
DashboardTaskHistoryResponse(**data)
|
DashboardTaskHistoryResponse(**data)
|
||||||
|
|
||||||
|
|
||||||
|
# [/DEF:test_get_dashboard_tasks_history_success:Function]
|
||||||
|
|
||||||
|
|
||||||
|
# [DEF:test_get_dashboard_tasks_history_sorting:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[TestDashboardsApi]
|
||||||
def test_get_dashboard_tasks_history_sorting(mock_deps):
|
def test_get_dashboard_tasks_history_sorting(mock_deps):
|
||||||
"""@POST: Response contains sorted task history (newest first)."""
|
"""@POST: Response contains sorted task history (newest first)."""
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
older = now - timedelta(hours=2)
|
older = now - timedelta(hours=2)
|
||||||
newest = now
|
newest = now
|
||||||
|
|
||||||
task_old = MagicMock(id="t-old", plugin_id="superset-backup", status="SUCCESS",
|
task_old = MagicMock(
|
||||||
started_at=older, finished_at=None,
|
id="t-old",
|
||||||
params={"env": "prod", "dashboards": [42]}, result={})
|
plugin_id="superset-backup",
|
||||||
task_new = MagicMock(id="t-new", plugin_id="superset-backup", status="RUNNING",
|
status="SUCCESS",
|
||||||
started_at=newest, finished_at=None,
|
started_at=older,
|
||||||
params={"env": "prod", "dashboards": [42]}, result={})
|
finished_at=None,
|
||||||
|
params={"env": "prod", "dashboards": [42]},
|
||||||
|
result={},
|
||||||
|
)
|
||||||
|
task_new = MagicMock(
|
||||||
|
id="t-new",
|
||||||
|
plugin_id="superset-backup",
|
||||||
|
status="RUNNING",
|
||||||
|
started_at=newest,
|
||||||
|
finished_at=None,
|
||||||
|
params={"env": "prod", "dashboards": [42]},
|
||||||
|
result={},
|
||||||
|
)
|
||||||
|
|
||||||
# Provide in wrong order to verify the endpoint sorts
|
# Provide in wrong order to verify the endpoint sorts
|
||||||
mock_deps["task"].get_all_tasks.return_value = [task_old, task_new]
|
mock_deps["task"].get_all_tasks.return_value = [task_old, task_new]
|
||||||
@@ -235,38 +363,67 @@ def test_get_dashboard_tasks_history_sorting(mock_deps):
|
|||||||
assert data["items"][0]["id"] == "t-new"
|
assert data["items"][0]["id"] == "t-new"
|
||||||
assert data["items"][1]["id"] == "t-old"
|
assert data["items"][1]["id"] == "t-old"
|
||||||
|
|
||||||
|
|
||||||
# --- 5. get_dashboard_thumbnail tests ---
|
# --- 5. get_dashboard_thumbnail tests ---
|
||||||
|
|
||||||
|
# [/DEF:test_get_dashboard_tasks_history_sorting:Function]
|
||||||
|
|
||||||
|
|
||||||
|
# [DEF:test_get_dashboard_thumbnail_success:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[TestDashboardsApi]
|
||||||
def test_get_dashboard_thumbnail_success(mock_deps):
|
def test_get_dashboard_thumbnail_success(mock_deps):
|
||||||
with patch("src.api.routes.dashboards.SupersetClient") as mock_client_cls:
|
with patch("src.api.routes.dashboards.SupersetClient") as mock_client_cls:
|
||||||
mock_env = MagicMock(); mock_env.id = "prod"
|
mock_env = MagicMock()
|
||||||
|
mock_env.id = "prod"
|
||||||
mock_deps["config"].get_environments.return_value = [mock_env]
|
mock_deps["config"].get_environments.return_value = [mock_env]
|
||||||
mock_client = MagicMock()
|
mock_client = MagicMock()
|
||||||
mock_response = MagicMock(status_code=200, content=b"img", headers={"Content-Type": "image/png"})
|
mock_response = MagicMock(
|
||||||
mock_client.network.request.side_effect = lambda method, endpoint, **kw: {"image_url": "url"} if method == "POST" else mock_response
|
status_code=200, content=b"img", headers={"Content-Type": "image/png"}
|
||||||
|
)
|
||||||
|
mock_client.network.request.side_effect = (
|
||||||
|
lambda method, endpoint, **kw: {"image_url": "url"}
|
||||||
|
if method == "POST"
|
||||||
|
else mock_response
|
||||||
|
)
|
||||||
mock_client_cls.return_value = mock_client
|
mock_client_cls.return_value = mock_client
|
||||||
|
|
||||||
response = client.get("/api/dashboards/42/thumbnail?env_id=prod")
|
response = client.get("/api/dashboards/42/thumbnail?env_id=prod")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.content == b"img"
|
assert response.content == b"img"
|
||||||
|
|
||||||
|
|
||||||
|
# [/DEF:test_get_dashboard_thumbnail_success:Function]
|
||||||
|
|
||||||
|
|
||||||
|
# [DEF:test_get_dashboard_thumbnail_env_not_found:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[TestDashboardsApi]
|
||||||
def test_get_dashboard_thumbnail_env_not_found(mock_deps):
|
def test_get_dashboard_thumbnail_env_not_found(mock_deps):
|
||||||
mock_deps["config"].get_environments.return_value = []
|
mock_deps["config"].get_environments.return_value = []
|
||||||
response = client.get("/api/dashboards/42/thumbnail?env_id=missing")
|
response = client.get("/api/dashboards/42/thumbnail?env_id=missing")
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# [/DEF:test_get_dashboard_thumbnail_env_not_found:Function]
|
||||||
|
|
||||||
|
|
||||||
|
# [DEF:test_get_dashboard_thumbnail_202:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[TestDashboardsApi]
|
||||||
def test_get_dashboard_thumbnail_202(mock_deps):
|
def test_get_dashboard_thumbnail_202(mock_deps):
|
||||||
"""@POST: Returns 202 when thumbnail is being prepared by Superset."""
|
"""@POST: Returns 202 when thumbnail is being prepared by Superset."""
|
||||||
with patch("src.api.routes.dashboards.SupersetClient") as mock_client_cls:
|
with patch("src.api.routes.dashboards.SupersetClient") as mock_client_cls:
|
||||||
mock_env = MagicMock(); mock_env.id = "prod"
|
mock_env = MagicMock()
|
||||||
|
mock_env.id = "prod"
|
||||||
mock_deps["config"].get_environments.return_value = [mock_env]
|
mock_deps["config"].get_environments.return_value = [mock_env]
|
||||||
mock_client = MagicMock()
|
mock_client = MagicMock()
|
||||||
|
|
||||||
# POST cache_dashboard_screenshot returns image_url
|
# POST cache_dashboard_screenshot returns image_url
|
||||||
mock_client.network.request.side_effect = [
|
mock_client.network.request.side_effect = [
|
||||||
{"image_url": "/api/v1/dashboard/42/thumbnail/abc123/"}, # POST
|
{"image_url": "/api/v1/dashboard/42/thumbnail/abc123/"}, # POST
|
||||||
MagicMock(status_code=202, json=lambda: {"message": "Thumbnail is being generated"},
|
MagicMock(
|
||||||
headers={"Content-Type": "application/json"}) # GET thumbnail -> 202
|
status_code=202,
|
||||||
|
json=lambda: {"message": "Thumbnail is being generated"},
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
), # GET thumbnail -> 202
|
||||||
]
|
]
|
||||||
mock_client_cls.return_value = mock_client
|
mock_client_cls.return_value = mock_client
|
||||||
|
|
||||||
@@ -274,74 +431,124 @@ def test_get_dashboard_thumbnail_202(mock_deps):
|
|||||||
assert response.status_code == 202
|
assert response.status_code == 202
|
||||||
assert "Thumbnail is being generated" in response.json()["message"]
|
assert "Thumbnail is being generated" in response.json()["message"]
|
||||||
|
|
||||||
|
|
||||||
# --- 6. migrate_dashboards tests ---
|
# --- 6. migrate_dashboards tests ---
|
||||||
|
|
||||||
|
# [/DEF:test_get_dashboard_thumbnail_202:Function]
|
||||||
|
|
||||||
|
|
||||||
|
# [DEF:test_migrate_dashboards_success:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[TestDashboardsApi]
|
||||||
def test_migrate_dashboards_success(mock_deps):
|
def test_migrate_dashboards_success(mock_deps):
|
||||||
mock_s = MagicMock(); mock_s.id = "s"
|
mock_s = MagicMock()
|
||||||
mock_t = MagicMock(); mock_t.id = "t"
|
mock_s.id = "s"
|
||||||
|
mock_t = MagicMock()
|
||||||
|
mock_t.id = "t"
|
||||||
mock_deps["config"].get_environments.return_value = [mock_s, mock_t]
|
mock_deps["config"].get_environments.return_value = [mock_s, mock_t]
|
||||||
mock_deps["task"].create_task = AsyncMock(return_value=MagicMock(id="task-123"))
|
mock_deps["task"].create_task = AsyncMock(return_value=MagicMock(id="task-123"))
|
||||||
|
|
||||||
response = client.post("/api/dashboards/migrate", json={
|
response = client.post(
|
||||||
"source_env_id": "s", "target_env_id": "t", "dashboard_ids": [1]
|
"/api/dashboards/migrate",
|
||||||
})
|
json={"source_env_id": "s", "target_env_id": "t", "dashboard_ids": [1]},
|
||||||
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json()["task_id"] == "task-123"
|
assert response.json()["task_id"] == "task-123"
|
||||||
|
|
||||||
|
|
||||||
|
# [/DEF:test_migrate_dashboards_success:Function]
|
||||||
|
|
||||||
|
|
||||||
|
# [DEF:test_migrate_dashboards_pre_checks:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[TestDashboardsApi]
|
||||||
def test_migrate_dashboards_pre_checks(mock_deps):
|
def test_migrate_dashboards_pre_checks(mock_deps):
|
||||||
# Missing IDs
|
# Missing IDs
|
||||||
response = client.post("/api/dashboards/migrate", json={
|
response = client.post(
|
||||||
"source_env_id": "s", "target_env_id": "t", "dashboard_ids": []
|
"/api/dashboards/migrate",
|
||||||
})
|
json={"source_env_id": "s", "target_env_id": "t", "dashboard_ids": []},
|
||||||
|
)
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
assert "At least one dashboard ID must be provided" in response.json()["detail"]
|
assert "At least one dashboard ID must be provided" in response.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
# [/DEF:test_migrate_dashboards_pre_checks:Function]
|
||||||
|
|
||||||
|
|
||||||
|
# [DEF:test_migrate_dashboards_env_not_found:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[TestDashboardsApi]
|
||||||
def test_migrate_dashboards_env_not_found(mock_deps):
|
def test_migrate_dashboards_env_not_found(mock_deps):
|
||||||
"""@PRE: source_env_id and target_env_id are valid environment IDs."""
|
"""@PRE: source_env_id and target_env_id are valid environment IDs."""
|
||||||
mock_deps["config"].get_environments.return_value = []
|
mock_deps["config"].get_environments.return_value = []
|
||||||
response = client.post("/api/dashboards/migrate", json={
|
response = client.post(
|
||||||
"source_env_id": "ghost", "target_env_id": "t", "dashboard_ids": [1]
|
"/api/dashboards/migrate",
|
||||||
})
|
json={"source_env_id": "ghost", "target_env_id": "t", "dashboard_ids": [1]},
|
||||||
|
)
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
assert "Source environment not found" in response.json()["detail"]
|
assert "Source environment not found" in response.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
# --- 7. backup_dashboards tests ---
|
# --- 7. backup_dashboards tests ---
|
||||||
|
|
||||||
|
# [/DEF:test_migrate_dashboards_env_not_found:Function]
|
||||||
|
|
||||||
|
|
||||||
|
# [DEF:test_backup_dashboards_success:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[TestDashboardsApi]
|
||||||
def test_backup_dashboards_success(mock_deps):
|
def test_backup_dashboards_success(mock_deps):
|
||||||
mock_env = MagicMock(); mock_env.id = "prod"
|
mock_env = MagicMock()
|
||||||
|
mock_env.id = "prod"
|
||||||
mock_deps["config"].get_environments.return_value = [mock_env]
|
mock_deps["config"].get_environments.return_value = [mock_env]
|
||||||
mock_deps["task"].create_task = AsyncMock(return_value=MagicMock(id="backup-123"))
|
mock_deps["task"].create_task = AsyncMock(return_value=MagicMock(id="backup-123"))
|
||||||
|
|
||||||
response = client.post("/api/dashboards/backup", json={
|
response = client.post(
|
||||||
"env_id": "prod", "dashboard_ids": [1]
|
"/api/dashboards/backup", json={"env_id": "prod", "dashboard_ids": [1]}
|
||||||
})
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json()["task_id"] == "backup-123"
|
assert response.json()["task_id"] == "backup-123"
|
||||||
|
|
||||||
|
|
||||||
|
# [/DEF:test_backup_dashboards_success:Function]
|
||||||
|
|
||||||
|
|
||||||
|
# [DEF:test_backup_dashboards_pre_checks:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[TestDashboardsApi]
|
||||||
def test_backup_dashboards_pre_checks(mock_deps):
|
def test_backup_dashboards_pre_checks(mock_deps):
|
||||||
response = client.post("/api/dashboards/backup", json={
|
response = client.post(
|
||||||
"env_id": "prod", "dashboard_ids": []
|
"/api/dashboards/backup", json={"env_id": "prod", "dashboard_ids": []}
|
||||||
})
|
)
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
# [/DEF:test_backup_dashboards_pre_checks:Function]
|
||||||
|
|
||||||
|
|
||||||
|
# [DEF:test_backup_dashboards_env_not_found:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[TestDashboardsApi]
|
||||||
def test_backup_dashboards_env_not_found(mock_deps):
|
def test_backup_dashboards_env_not_found(mock_deps):
|
||||||
"""@PRE: env_id is a valid environment ID."""
|
"""@PRE: env_id is a valid environment ID."""
|
||||||
mock_deps["config"].get_environments.return_value = []
|
mock_deps["config"].get_environments.return_value = []
|
||||||
response = client.post("/api/dashboards/backup", json={
|
response = client.post(
|
||||||
"env_id": "ghost", "dashboard_ids": [1]
|
"/api/dashboards/backup", json={"env_id": "ghost", "dashboard_ids": [1]}
|
||||||
})
|
)
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
assert "Environment not found" in response.json()["detail"]
|
assert "Environment not found" in response.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
# [/DEF:test_backup_dashboards_env_not_found:Function]
|
||||||
|
|
||||||
|
|
||||||
|
# [DEF:test_backup_dashboards_with_schedule:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[TestDashboardsApi]
|
||||||
def test_backup_dashboards_with_schedule(mock_deps):
|
def test_backup_dashboards_with_schedule(mock_deps):
|
||||||
"""@POST: If schedule is provided, a scheduled task is created."""
|
"""@POST: If schedule is provided, a scheduled task is created."""
|
||||||
mock_env = MagicMock(); mock_env.id = "prod"
|
mock_env = MagicMock()
|
||||||
|
mock_env.id = "prod"
|
||||||
mock_deps["config"].get_environments.return_value = [mock_env]
|
mock_deps["config"].get_environments.return_value = [mock_env]
|
||||||
mock_deps["task"].create_task = AsyncMock(return_value=MagicMock(id="sched-456"))
|
mock_deps["task"].create_task = AsyncMock(return_value=MagicMock(id="sched-456"))
|
||||||
|
|
||||||
response = client.post("/api/dashboards/backup", json={
|
response = client.post(
|
||||||
"env_id": "prod", "dashboard_ids": [1], "schedule": "0 0 * * *"
|
"/api/dashboards/backup",
|
||||||
})
|
json={"env_id": "prod", "dashboard_ids": [1], "schedule": "0 0 * * *"},
|
||||||
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json()["task_id"] == "sched-456"
|
assert response.json()["task_id"] == "sched-456"
|
||||||
|
|
||||||
@@ -350,17 +557,30 @@ def test_backup_dashboards_with_schedule(mock_deps):
|
|||||||
task_params = call_kwargs.kwargs.get("params") or call_kwargs[1].get("params", {})
|
task_params = call_kwargs.kwargs.get("params") or call_kwargs[1].get("params", {})
|
||||||
assert task_params["schedule"] == "0 0 * * *"
|
assert task_params["schedule"] == "0 0 * * *"
|
||||||
|
|
||||||
|
|
||||||
# --- 8. Internal logic: _task_matches_dashboard ---
|
# --- 8. Internal logic: _task_matches_dashboard ---
|
||||||
|
# [/DEF:test_backup_dashboards_with_schedule:Function]
|
||||||
|
|
||||||
from src.api.routes.dashboards import _task_matches_dashboard
|
from src.api.routes.dashboards import _task_matches_dashboard
|
||||||
|
|
||||||
|
|
||||||
|
# [DEF:test_task_matches_dashboard_logic:Function]
|
||||||
|
# @RELATION: BINDS_TO ->[TestDashboardsApi]
|
||||||
def test_task_matches_dashboard_logic():
|
def test_task_matches_dashboard_logic():
|
||||||
task = MagicMock(plugin_id="superset-backup", params={"dashboards": [42], "env": "prod"})
|
task = MagicMock(
|
||||||
|
plugin_id="superset-backup", params={"dashboards": [42], "env": "prod"}
|
||||||
|
)
|
||||||
assert _task_matches_dashboard(task, 42, "prod") is True
|
assert _task_matches_dashboard(task, 42, "prod") is True
|
||||||
assert _task_matches_dashboard(task, 43, "prod") is False
|
assert _task_matches_dashboard(task, 43, "prod") is False
|
||||||
assert _task_matches_dashboard(task, 42, "dev") is False
|
assert _task_matches_dashboard(task, 42, "dev") is False
|
||||||
|
|
||||||
llm_task = MagicMock(plugin_id="llm_dashboard_validation", params={"dashboard_id": 42, "environment_id": "prod"})
|
llm_task = MagicMock(
|
||||||
|
plugin_id="llm_dashboard_validation",
|
||||||
|
params={"dashboard_id": 42, "environment_id": "prod"},
|
||||||
|
)
|
||||||
assert _task_matches_dashboard(llm_task, 42, "prod") is True
|
assert _task_matches_dashboard(llm_task, 42, "prod") is True
|
||||||
assert _task_matches_dashboard(llm_task, 42, None) is True
|
assert _task_matches_dashboard(llm_task, 42, None) is True
|
||||||
|
|
||||||
# [/DEF:backend.tests.test_dashboards_api:Module]
|
|
||||||
|
# [/DEF:test_task_matches_dashboard_logic:Function]
|
||||||
|
# [/DEF:TestDashboardsApi:Module]
|
||||||
|
|||||||
Reference in New Issue
Block a user