- Dataset viewer: sed-замена (find→replace) по всем/выбранным/текущим датасетам с обязательным визуальным предпросмотром и целями (sql/metrics/yaml); сохранение и выбор именованных правил (sedRules store + SedRuleEditor). - Migration: literal-замена в dataset YAML при переносе; рескан ID чартов/датасетов перед миграцией + метрика средней длительности синка (GET /migration/sync-stats); логическая группировка опций (маппинги БД, сервер изменений, rescan) под галочками. - Fix: дашборды хаба скрывались дефолтным профиль-фильтром show_only_slug_dashboards=true (теперь false); мастер миграции не грузил дашборды предзаполненного источника; потеря терминального task_status из-за утечки pending-корутин в /ws/logs (панель результата не появлялась); горизонтальный скролл в MappingTable; чистая per-env ошибка в mapping coverage. - Backend: record_sync_duration + alembic-миграция sync-duration; literal_replace модуль.
342 lines
14 KiB
Python
342 lines
14 KiB
Python
# #region Test.ProfileUtils [C:3] [TYPE Module] [SEMANTICS test,profile,utils,pure-functions]
|
|
# @BRIEF Tests for profile_utils.py — sanitize, normalize, mask, validate, default preference, owner tokens, exceptions.
|
|
# @RELATION BINDS_TO -> [Services.ProfileUtils]
|
|
# @TEST_EDGE: missing_field -> None/empty inputs handled gracefully
|
|
# @TEST_EDGE: invalid_type -> Invalid values produce validation errors
|
|
# @TEST_EDGE: external_fail -> N/A (pure functions, no external deps)
|
|
|
|
from pathlib import Path
|
|
import sys
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
|
|
|
import pytest
|
|
|
|
from src.services.profile_utils import (
|
|
EnvironmentNotFoundError,
|
|
ProfileAuthorizationError,
|
|
ProfileValidationError,
|
|
build_default_preference,
|
|
mask_secret_value,
|
|
normalize_density,
|
|
normalize_owner_tokens,
|
|
normalize_start_page,
|
|
normalize_username,
|
|
sanitize_secret,
|
|
sanitize_text,
|
|
sanitize_username,
|
|
validate_update_payload,
|
|
)
|
|
|
|
|
|
def _validate(**overrides):
|
|
defaults = dict(
|
|
superset_username="admin",
|
|
show_only_my_dashboards=False,
|
|
git_email=None,
|
|
start_page="dashboards",
|
|
dashboards_table_density="comfortable",
|
|
)
|
|
defaults.update(overrides)
|
|
return validate_update_payload(**defaults)
|
|
|
|
|
|
# --- sanitize_text ---
|
|
|
|
# #region Test.ProfileUtils.TestSanitizeText [C:2] [TYPE Function]
|
|
# @BRIEF Verify sanitize_text with None, empty, whitespace, and valid inputs.
|
|
@pytest.mark.parametrize("value, expected", [
|
|
(None, None), ("", None), (" ", None),
|
|
(" hello ", "hello"), ("world", "world"),
|
|
])
|
|
def test_sanitize_text(value, expected):
|
|
assert sanitize_text(value) == expected
|
|
# #endregion Test.ProfileUtils.TestSanitizeText
|
|
|
|
# --- sanitize_secret ---
|
|
|
|
# #region Test.ProfileUtils.TestSanitizeSecret [C:2] [TYPE Function]
|
|
# @BRIEF Verify sanitize_secret with None, empty, whitespace, and valid secrets.
|
|
@pytest.mark.parametrize("value, expected", [
|
|
(None, None), ("", None), (" ", None),
|
|
(" my-token-abc ", "my-token-abc"),
|
|
])
|
|
def test_sanitize_secret(value, expected):
|
|
assert sanitize_secret(value) == expected
|
|
# #endregion Test.ProfileUtils.TestSanitizeSecret
|
|
|
|
# --- sanitize_username ---
|
|
|
|
# #region Test.ProfileUtils.TestSanitizeUsername [C:2] [TYPE Function]
|
|
# @BRIEF Verify sanitize_username delegates to sanitize_text.
|
|
@pytest.mark.parametrize("value, expected", [
|
|
(None, None), (" admin ", "admin"),
|
|
])
|
|
def test_sanitize_username(value, expected):
|
|
assert sanitize_username(value) == expected
|
|
# #endregion Test.ProfileUtils.TestSanitizeUsername
|
|
|
|
# --- normalize_username ---
|
|
|
|
# #region Test.ProfileUtils.TestNormalizeUsername [C:2] [TYPE Function]
|
|
# @BRIEF Verify trim+lower normalization for actor matching.
|
|
@pytest.mark.parametrize("value, expected", [
|
|
(None, None), ("", None),
|
|
(" AdminUser ", "adminuser"), ("guest", "guest"),
|
|
])
|
|
def test_normalize_username(value, expected):
|
|
assert normalize_username(value) == expected
|
|
# #endregion Test.ProfileUtils.TestNormalizeUsername
|
|
|
|
# --- normalize_start_page ---
|
|
|
|
# #region Test.ProfileUtils.TestNormalizeStartPage [C:2] [TYPE Function]
|
|
# @BRIEF Verify start page alias mapping to canonical values.
|
|
@pytest.mark.parametrize("value, expected", [
|
|
("reports-logs", "reports"), ("dashboards", "dashboards"),
|
|
("datasets", "datasets"), ("reports", "reports"),
|
|
("settings", "dashboards"), (None, "dashboards"),
|
|
("DATASETS", "datasets"),
|
|
])
|
|
def test_normalize_start_page(value, expected):
|
|
assert normalize_start_page(value) == expected
|
|
# #endregion Test.ProfileUtils.TestNormalizeStartPage
|
|
|
|
# --- normalize_density ---
|
|
|
|
# #region Test.ProfileUtils.TestNormalizeDensity [C:2] [TYPE Function]
|
|
# @BRIEF Verify density alias mapping to canonical values.
|
|
@pytest.mark.parametrize("value, expected", [
|
|
("free", "comfortable"), ("compact", "compact"),
|
|
("comfortable", "comfortable"), ("spacious", "comfortable"),
|
|
(None, "comfortable"),
|
|
])
|
|
def test_normalize_density(value, expected):
|
|
assert normalize_density(value) == expected
|
|
# #endregion Test.ProfileUtils.TestNormalizeDensity
|
|
|
|
# --- mask_secret_value ---
|
|
|
|
# #region Test.ProfileUtils.TestMaskSecretValue [C:2] [TYPE Function]
|
|
# @BRIEF Verify safe display masking for secrets of various lengths.
|
|
@pytest.mark.parametrize("value, expected", [
|
|
(None, None), ("", None),
|
|
("ab", "***"), ("abcd", "***"),
|
|
("my-token-value", "my***ue"), (" ab ", "***"),
|
|
])
|
|
def test_mask_secret_value(value, expected):
|
|
assert mask_secret_value(value) == expected
|
|
# #endregion Test.ProfileUtils.TestMaskSecretValue
|
|
|
|
# --- validate_update_payload ---
|
|
|
|
# #region Test.ProfileUtils.TestValidateValidPayload [C:2] [TYPE Function]
|
|
# @BRIEF All-valid input returns empty error list.
|
|
def test_validate_valid_payload():
|
|
assert _validate() == []
|
|
# #endregion Test.ProfileUtils.TestValidateValidPayload
|
|
|
|
# #region Test.ProfileUtils.TestValidateUsernameWithSpace [C:2] [TYPE Function]
|
|
# @BRIEF Username containing space produces validation error.
|
|
def test_validate_username_with_space():
|
|
errors = _validate(superset_username="admin user")
|
|
assert any("space" in e.lower() for e in errors)
|
|
# #endregion Test.ProfileUtils.TestValidateUsernameWithSpace
|
|
|
|
# #region Test.ProfileUtils.TestValidateShowOnlyRequiresUsername [C:2] [TYPE Function]
|
|
# @BRIEF show_only_my_dashboards=True without username produces error.
|
|
def test_validate_show_only_requires_username():
|
|
errors = _validate(superset_username=None, show_only_my_dashboards=True)
|
|
assert any("username" in e.lower() and "required" in e.lower() for e in errors)
|
|
# #endregion Test.ProfileUtils.TestValidateShowOnlyRequiresUsername
|
|
|
|
# #region Test.ProfileUtils.TestValidateGitEmailMissingAt [C:2] [TYPE Function]
|
|
# @BRIEF Git email without @ produces validation error.
|
|
def test_validate_git_email_missing_at():
|
|
errors = _validate(git_email="not-an-email")
|
|
assert any("git email" in e.lower() for e in errors)
|
|
# #endregion Test.ProfileUtils.TestValidateGitEmailMissingAt
|
|
|
|
# #region Test.ProfileUtils.TestValidateGitEmailWithSpace [C:2] [TYPE Function]
|
|
# @BRIEF Git email containing space produces validation error.
|
|
def test_validate_git_email_with_space():
|
|
errors = _validate(git_email="admin @x.com")
|
|
assert any("git email" in e.lower() for e in errors)
|
|
# #endregion Test.ProfileUtils.TestValidateGitEmailWithSpace
|
|
|
|
# #region Test.ProfileUtils.TestValidateGitEmailStartsWithAt [C:2] [TYPE Function]
|
|
# @BRIEF Git email starting with @ produces validation error.
|
|
def test_validate_git_email_starts_with_at():
|
|
errors = _validate(git_email="@example.com")
|
|
assert any("git email" in e.lower() for e in errors)
|
|
# #endregion Test.ProfileUtils.TestValidateGitEmailStartsWithAt
|
|
|
|
# #region Test.ProfileUtils.TestValidateGitEmailEndsWithAt [C:2] [TYPE Function]
|
|
# @BRIEF Git email ending with @ produces validation error.
|
|
def test_validate_git_email_ends_with_at():
|
|
errors = _validate(git_email="admin@")
|
|
assert any("git email" in e.lower() for e in errors)
|
|
# #endregion Test.ProfileUtils.TestValidateGitEmailEndsWithAt
|
|
|
|
# #region Test.ProfileUtils.TestValidateInvalidStartPage [C:2] [TYPE Function]
|
|
# @BRIEF Unsupported start_page value produces validation error.
|
|
def test_validate_invalid_start_page():
|
|
errors = _validate(start_page="settings")
|
|
assert any("start page" in e.lower() for e in errors)
|
|
# #endregion Test.ProfileUtils.TestValidateInvalidStartPage
|
|
|
|
# #region Test.ProfileUtils.TestValidateInvalidDensity [C:2] [TYPE Function]
|
|
# @BRIEF Unsupported density value produces validation error.
|
|
def test_validate_invalid_density():
|
|
errors = _validate(dashboards_table_density="spacious")
|
|
assert any("density" in e.lower() for e in errors)
|
|
# #endregion Test.ProfileUtils.TestValidateInvalidDensity
|
|
|
|
# #region Test.ProfileUtils.TestValidateInvalidNotificationEmail [C:2] [TYPE Function]
|
|
# @BRIEF Invalid notification email variants all produce validation error.
|
|
@pytest.mark.parametrize("bad_email", ["bad-email", "@x.com", "a@", "a b@c.com"])
|
|
def test_validate_invalid_notification_email(bad_email):
|
|
errors = _validate(email_address=bad_email)
|
|
assert any("notification email" in e.lower() for e in errors)
|
|
# #endregion Test.ProfileUtils.TestValidateInvalidNotificationEmail
|
|
|
|
# #region Test.ProfileUtils.TestValidateValidNotificationEmail [C:2] [TYPE Function]
|
|
# @BRIEF Valid notification email produces no notification error.
|
|
def test_validate_valid_notification_email():
|
|
errors = _validate(email_address="user@example.com")
|
|
assert not any("notification email" in e.lower() for e in errors)
|
|
# #endregion Test.ProfileUtils.TestValidateValidNotificationEmail
|
|
|
|
# #region Test.ProfileUtils.TestValidateMultipleErrors [C:2] [TYPE Function]
|
|
# @BRIEF Multiple invalid fields produce multiple accumulated errors.
|
|
def test_validate_multiple_errors():
|
|
errors = _validate(
|
|
superset_username="admin user",
|
|
show_only_my_dashboards=True,
|
|
git_email="bad",
|
|
start_page="unknown",
|
|
dashboards_table_density="spacious",
|
|
)
|
|
assert len(errors) >= 4
|
|
# #endregion Test.ProfileUtils.TestValidateMultipleErrors
|
|
|
|
# --- build_default_preference ---
|
|
|
|
# #region Test.ProfileUtils.TestBuildDefaultPreference [C:2] [TYPE Function]
|
|
# @BRIEF Verify default preference DTO has correct initial values for all fields.
|
|
def test_build_default_preference():
|
|
pref = build_default_preference("user-1")
|
|
assert pref.user_id == "user-1"
|
|
assert pref.superset_username is None
|
|
assert pref.superset_username_normalized is None
|
|
assert pref.show_only_my_dashboards is False
|
|
assert pref.show_only_slug_dashboards is False
|
|
assert pref.git_username is None
|
|
assert pref.git_email is None
|
|
assert pref.has_git_personal_access_token is False
|
|
assert pref.git_personal_access_token_masked is None
|
|
assert pref.start_page == "dashboards"
|
|
assert pref.auto_open_task_drawer is True
|
|
assert pref.dashboards_table_density == "comfortable"
|
|
assert pref.telegram_id is None
|
|
assert pref.email_address is None
|
|
assert pref.notify_on_fail is True
|
|
assert pref.created_at == pref.updated_at
|
|
# #endregion Test.ProfileUtils.TestBuildDefaultPreference
|
|
|
|
# --- normalize_owner_tokens ---
|
|
|
|
# #region Test.ProfileUtils.TestOwnerTokensNone [C:2] [TYPE Function]
|
|
# @BRIEF None input returns empty list.
|
|
def test_owner_tokens_none():
|
|
assert normalize_owner_tokens(None) == []
|
|
# #endregion Test.ProfileUtils.TestOwnerTokensNone
|
|
|
|
# #region Test.ProfileUtils.TestOwnerTokensEmpty [C:2] [TYPE Function]
|
|
# @BRIEF Empty iterable returns empty list.
|
|
def test_owner_tokens_empty():
|
|
assert normalize_owner_tokens([]) == []
|
|
# #endregion Test.ProfileUtils.TestOwnerTokensEmpty
|
|
|
|
# #region Test.ProfileUtils.TestOwnerTokensDictUsername [C:2] [TYPE Function]
|
|
# @BRIEF Dict with username field produces normalized token.
|
|
def test_owner_tokens_dict_username():
|
|
tokens = normalize_owner_tokens([{"username": "AdminUser"}])
|
|
assert "adminuser" in tokens
|
|
# #endregion Test.ProfileUtils.TestOwnerTokensDictUsername
|
|
|
|
# #region Test.ProfileUtils.TestOwnerTokensDictNames [C:2] [TYPE Function]
|
|
# @BRIEF Dict with first/last name produces individual and combined tokens.
|
|
def test_owner_tokens_dict_names():
|
|
tokens = normalize_owner_tokens([{"first_name": "John", "last_name": "Doe"}])
|
|
assert "john" in tokens
|
|
assert "doe" in tokens
|
|
assert "john_doe" in tokens
|
|
# #endregion Test.ProfileUtils.TestOwnerTokensDictNames
|
|
|
|
# #region Test.ProfileUtils.TestOwnerTokensString [C:2] [TYPE Function]
|
|
# @BRIEF Plain string items are normalized directly.
|
|
def test_owner_tokens_string():
|
|
tokens = normalize_owner_tokens([" Admin "])
|
|
assert "admin" in tokens
|
|
# #endregion Test.ProfileUtils.TestOwnerTokensString
|
|
|
|
# #region Test.ProfileUtils.TestOwnerTokensDedup [C:2] [TYPE Function]
|
|
# @BRIEF Duplicate tokens after normalization are collapsed.
|
|
def test_owner_tokens_dedup():
|
|
tokens = normalize_owner_tokens(["admin", " Admin "])
|
|
assert tokens == ["admin"]
|
|
# #endregion Test.ProfileUtils.TestOwnerTokensDedup
|
|
|
|
# #region Test.ProfileUtils.TestOwnerTokensSkipEmpty [C:2] [TYPE Function]
|
|
# @BRIEF Empty/whitespace/None items produce no tokens.
|
|
def test_owner_tokens_skip_empty():
|
|
assert normalize_owner_tokens(["", " ", None]) == []
|
|
# #endregion Test.ProfileUtils.TestOwnerTokensSkipEmpty
|
|
|
|
# #region Test.ProfileUtils.TestOwnerTokensDictEmail [C:2] [TYPE Function]
|
|
# @BRIEF Dict with email field produces normalized email token.
|
|
def test_owner_tokens_dict_email():
|
|
tokens = normalize_owner_tokens([{"email": "User@Example.com"}])
|
|
assert "user@example.com" in tokens
|
|
# #endregion Test.ProfileUtils.TestOwnerTokensDictEmail
|
|
|
|
# #region Test.ProfileUtils.TestOwnerTokensDictMulti [C:2] [TYPE Function]
|
|
# @BRIEF Dict with multiple fields produces all valid candidate tokens.
|
|
def test_owner_tokens_dict_multi():
|
|
owners = [{"username": "jdoe", "first_name": "John", "last_name": "Doe", "email": "john@example.com"}]
|
|
tokens = normalize_owner_tokens(owners)
|
|
assert "jdoe" in tokens
|
|
assert "john" in tokens
|
|
assert "doe" in tokens
|
|
assert "john@example.com" in tokens
|
|
# #endregion Test.ProfileUtils.TestOwnerTokensDictMulti
|
|
|
|
# --- exception classes ---
|
|
|
|
# #region Test.ProfileUtils.TestExceptionProfileValidationError [C:2] [TYPE Function]
|
|
# @BRIEF ProfileValidationError stores errors list and has expected message.
|
|
def test_exception_profile_validation_error():
|
|
err = ProfileValidationError(["field1 bad", "field2 bad"])
|
|
assert err.errors == ["field1 bad", "field2 bad"]
|
|
assert "validation failed" in str(err).lower()
|
|
with pytest.raises(ProfileValidationError):
|
|
raise ProfileValidationError(["test"])
|
|
# #endregion Test.ProfileUtils.TestExceptionProfileValidationError
|
|
|
|
# #region Test.ProfileUtils.TestExceptionEnvironmentNotFound [C:2] [TYPE Function]
|
|
# @BRIEF EnvironmentNotFoundError can be raised and caught as Exception.
|
|
def test_exception_environment_not_found():
|
|
with pytest.raises(EnvironmentNotFoundError):
|
|
raise EnvironmentNotFoundError("env-xyz")
|
|
# #endregion Test.ProfileUtils.TestExceptionEnvironmentNotFound
|
|
|
|
# #region Test.ProfileUtils.TestExceptionProfileAuthorization [C:2] [TYPE Function]
|
|
# @BRIEF ProfileAuthorizationError can be raised and caught as Exception.
|
|
def test_exception_profile_authorization():
|
|
with pytest.raises(ProfileAuthorizationError):
|
|
raise ProfileAuthorizationError("cross-user")
|
|
# #endregion Test.ProfileUtils.TestExceptionProfileAuthorization
|
|
|
|
# #endregion Test.ProfileUtils
|