Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 50d74c454e | |||
| 098257efdc |
@@ -1,314 +0,0 @@
|
||||
"""TtsConfig 单元测试."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from domain.tts_config import TtsConfig
|
||||
|
||||
|
||||
class TestTtsConfigDefaults:
|
||||
"""默认值测试."""
|
||||
|
||||
def test_default_values(self):
|
||||
config = TtsConfig()
|
||||
assert config.enabled is False
|
||||
assert config.voice_id == ""
|
||||
assert config.speed == 1.0
|
||||
assert config.pitch == 0.0
|
||||
assert config.volume == 0.8
|
||||
assert config.text == ""
|
||||
assert config.align_mode == "full"
|
||||
assert config.overlap_mode == "replace"
|
||||
|
||||
def test_custom_construction(self):
|
||||
config = TtsConfig(
|
||||
enabled=True,
|
||||
voice_id="voice_001",
|
||||
speed=1.5,
|
||||
pitch=3.0,
|
||||
volume=0.9,
|
||||
text="hello",
|
||||
align_mode="subtitle",
|
||||
overlap_mode="mix",
|
||||
)
|
||||
assert config.enabled is True
|
||||
assert config.voice_id == "voice_001"
|
||||
assert config.speed == 1.5
|
||||
assert config.pitch == 3.0
|
||||
assert config.volume == 0.9
|
||||
assert config.text == "hello"
|
||||
assert config.align_mode == "subtitle"
|
||||
assert config.overlap_mode == "mix"
|
||||
|
||||
def test_slots_no_extra_attrs(self):
|
||||
config = TtsConfig()
|
||||
with pytest.raises((AttributeError, TypeError)):
|
||||
config.new_attr = "value" # type: ignore[attr-defined]
|
||||
|
||||
def test_equality_same_values(self):
|
||||
a = TtsConfig(enabled=True, voice_id="v1")
|
||||
b = TtsConfig(enabled=True, voice_id="v1")
|
||||
assert a == b
|
||||
|
||||
def test_equality_different_values(self):
|
||||
a = TtsConfig(enabled=True)
|
||||
b = TtsConfig(enabled=False)
|
||||
assert a != b
|
||||
|
||||
|
||||
class TestTtsConfigParseNoneAndEmpty:
|
||||
"""parse 空输入测试."""
|
||||
|
||||
def test_parse_none(self):
|
||||
config = TtsConfig.parse(None)
|
||||
assert config == TtsConfig()
|
||||
|
||||
def test_parse_empty_dict(self):
|
||||
config = TtsConfig.parse({})
|
||||
assert config == TtsConfig()
|
||||
|
||||
def test_parse_non_dict_string(self):
|
||||
config = TtsConfig.parse("not a dict") # type: ignore[arg-type]
|
||||
assert config == TtsConfig()
|
||||
|
||||
def test_parse_non_dict_list(self):
|
||||
config = TtsConfig.parse([]) # type: ignore[arg-type]
|
||||
assert config == TtsConfig()
|
||||
|
||||
def test_parse_non_dict_number(self):
|
||||
config = TtsConfig.parse(123) # type: ignore[arg-type]
|
||||
assert config == TtsConfig()
|
||||
|
||||
|
||||
class TestTtsConfigParseDisabled:
|
||||
"""parse disabled 场景."""
|
||||
|
||||
def test_parse_enabled_false_returns_default(self):
|
||||
config = TtsConfig.parse({"enabled": False})
|
||||
assert config.enabled is False
|
||||
assert config.speed == 1.0
|
||||
assert config.voice_id == ""
|
||||
|
||||
def test_parse_enabled_false_ignores_other_fields(self):
|
||||
config = TtsConfig.parse(
|
||||
{
|
||||
"enabled": False,
|
||||
"voice_id": "v1",
|
||||
"speed": 1.5,
|
||||
}
|
||||
)
|
||||
assert config.enabled is False
|
||||
assert config.voice_id == ""
|
||||
assert config.speed == 1.0
|
||||
|
||||
def test_parse_enabled_non_bool_falls_to_false(self):
|
||||
config = TtsConfig.parse({"enabled": "true"})
|
||||
assert config.enabled is False
|
||||
|
||||
def test_parse_enabled_int_falls_to_false(self):
|
||||
config = TtsConfig.parse({"enabled": 1})
|
||||
assert config.enabled is False
|
||||
|
||||
|
||||
class TestTtsConfigParseNormal:
|
||||
"""parse 正常数据测试."""
|
||||
|
||||
def test_parse_full_data(self):
|
||||
data = {
|
||||
"enabled": True,
|
||||
"voice_id": "voice_001",
|
||||
"speed": 1.5,
|
||||
"pitch": 2.5,
|
||||
"volume": 0.7,
|
||||
"text": "你好世界",
|
||||
"align_mode": "subtitle",
|
||||
"overlap_mode": "mix",
|
||||
}
|
||||
config = TtsConfig.parse(data)
|
||||
assert config.enabled is True
|
||||
assert config.voice_id == "voice_001"
|
||||
assert config.speed == 1.5
|
||||
assert config.pitch == 2.5
|
||||
assert config.volume == 0.7
|
||||
assert config.text == "你好世界"
|
||||
assert config.align_mode == "subtitle"
|
||||
assert config.overlap_mode == "mix"
|
||||
|
||||
def test_parse_int_speed_becomes_float(self):
|
||||
config = TtsConfig.parse({"enabled": True, "speed": 2})
|
||||
assert isinstance(config.speed, float)
|
||||
assert config.speed == 2.0
|
||||
|
||||
def test_parse_int_pitch_becomes_float(self):
|
||||
config = TtsConfig.parse({"enabled": True, "pitch": -3})
|
||||
assert isinstance(config.pitch, float)
|
||||
assert config.pitch == -3.0
|
||||
|
||||
|
||||
class TestTtsConfigParseTypeFallback:
|
||||
"""parse 类型错误回退测试."""
|
||||
|
||||
def test_parse_voice_id_non_string_fallback(self):
|
||||
config = TtsConfig.parse({"enabled": True, "voice_id": 123})
|
||||
assert config.voice_id == ""
|
||||
|
||||
def test_parse_speed_non_numeric_fallback(self):
|
||||
config = TtsConfig.parse({"enabled": True, "speed": "fast"})
|
||||
assert config.speed == 1.0
|
||||
|
||||
def test_parse_pitch_non_numeric_fallback(self):
|
||||
config = TtsConfig.parse({"enabled": True, "pitch": "high"})
|
||||
assert config.pitch == 0.0
|
||||
|
||||
def test_parse_volume_non_numeric_fallback(self):
|
||||
config = TtsConfig.parse({"enabled": True, "volume": "loud"})
|
||||
assert config.volume == 0.8
|
||||
|
||||
def test_parse_text_non_string_fallback(self):
|
||||
config = TtsConfig.parse({"enabled": True, "text": 456})
|
||||
assert config.text == ""
|
||||
|
||||
def test_parse_voice_id_list_fallback(self):
|
||||
config = TtsConfig.parse({"enabled": True, "voice_id": ["v1"]})
|
||||
assert config.voice_id == ""
|
||||
|
||||
|
||||
class TestTtsConfigParseClamp:
|
||||
"""parse 边界钳制测试."""
|
||||
|
||||
def test_parse_speed_below_min_clamped(self):
|
||||
config = TtsConfig.parse({"enabled": True, "speed": 0.1})
|
||||
assert config.speed == 0.5
|
||||
|
||||
def test_parse_speed_above_max_clamped(self):
|
||||
config = TtsConfig.parse({"enabled": True, "speed": 3.0})
|
||||
assert config.speed == 2.0
|
||||
|
||||
def test_parse_speed_at_min_ok(self):
|
||||
config = TtsConfig.parse({"enabled": True, "speed": 0.5})
|
||||
assert config.speed == 0.5
|
||||
|
||||
def test_parse_speed_at_max_ok(self):
|
||||
config = TtsConfig.parse({"enabled": True, "speed": 2.0})
|
||||
assert config.speed == 2.0
|
||||
|
||||
def test_parse_pitch_below_min_clamped(self):
|
||||
config = TtsConfig.parse({"enabled": True, "pitch": -20})
|
||||
assert config.pitch == -12
|
||||
|
||||
def test_parse_pitch_above_max_clamped(self):
|
||||
config = TtsConfig.parse({"enabled": True, "pitch": 20})
|
||||
assert config.pitch == 12
|
||||
|
||||
def test_parse_pitch_at_min_ok(self):
|
||||
config = TtsConfig.parse({"enabled": True, "pitch": -12})
|
||||
assert config.pitch == -12
|
||||
|
||||
def test_parse_pitch_at_max_ok(self):
|
||||
config = TtsConfig.parse({"enabled": True, "pitch": 12})
|
||||
assert config.pitch == 12
|
||||
|
||||
def test_parse_volume_below_min_clamped(self):
|
||||
config = TtsConfig.parse({"enabled": True, "volume": -0.5})
|
||||
assert config.volume == 0.0
|
||||
|
||||
def test_parse_volume_above_max_clamped(self):
|
||||
config = TtsConfig.parse({"enabled": True, "volume": 1.5})
|
||||
assert config.volume == 1.0
|
||||
|
||||
def test_parse_volume_at_min_ok(self):
|
||||
config = TtsConfig.parse({"enabled": True, "volume": 0.0})
|
||||
assert config.volume == 0.0
|
||||
|
||||
def test_parse_volume_at_max_ok(self):
|
||||
config = TtsConfig.parse({"enabled": True, "volume": 1.0})
|
||||
assert config.volume == 1.0
|
||||
|
||||
|
||||
class TestTtsConfigParseAlignMode:
|
||||
"""align_mode 解析测试."""
|
||||
|
||||
def test_parse_align_mode_subtitle(self):
|
||||
config = TtsConfig.parse({"enabled": True, "align_mode": "subtitle"})
|
||||
assert config.align_mode == "subtitle"
|
||||
|
||||
def test_parse_align_mode_full(self):
|
||||
config = TtsConfig.parse({"enabled": True, "align_mode": "full"})
|
||||
assert config.align_mode == "full"
|
||||
|
||||
def test_parse_align_mode_invalid_fallback(self):
|
||||
config = TtsConfig.parse({"enabled": True, "align_mode": "auto"})
|
||||
assert config.align_mode == "full"
|
||||
|
||||
def test_parse_align_mode_empty_fallback(self):
|
||||
config = TtsConfig.parse({"enabled": True, "align_mode": ""})
|
||||
assert config.align_mode == "full"
|
||||
|
||||
|
||||
class TestTtsConfigParseOverlapMode:
|
||||
"""overlap_mode 解析测试."""
|
||||
|
||||
def test_parse_overlap_mode_replace(self):
|
||||
config = TtsConfig.parse({"enabled": True, "overlap_mode": "replace"})
|
||||
assert config.overlap_mode == "replace"
|
||||
|
||||
def test_parse_overlap_mode_mix(self):
|
||||
config = TtsConfig.parse({"enabled": True, "overlap_mode": "mix"})
|
||||
assert config.overlap_mode == "mix"
|
||||
|
||||
def test_parse_overlap_mode_invalid_fallback(self):
|
||||
config = TtsConfig.parse({"enabled": True, "overlap_mode": "add"})
|
||||
assert config.overlap_mode == "replace"
|
||||
|
||||
def test_parse_overlap_mode_empty_fallback(self):
|
||||
config = TtsConfig.parse({"enabled": True, "overlap_mode": ""})
|
||||
assert config.overlap_mode == "replace"
|
||||
|
||||
|
||||
class TestTtsConfigClamp:
|
||||
"""_clamp 直接调用测试."""
|
||||
|
||||
def test_clamp_speed_low(self):
|
||||
config = TtsConfig(enabled=True, speed=0.1)
|
||||
config._clamp()
|
||||
assert config.speed == 0.5
|
||||
|
||||
def test_clamp_speed_high(self):
|
||||
config = TtsConfig(enabled=True, speed=5.0)
|
||||
config._clamp()
|
||||
assert config.speed == 2.0
|
||||
|
||||
def test_clamp_speed_normal_unchanged(self):
|
||||
config = TtsConfig(enabled=True, speed=1.2)
|
||||
config._clamp()
|
||||
assert config.speed == 1.2
|
||||
|
||||
def test_clamp_pitch_low(self):
|
||||
config = TtsConfig(enabled=True, pitch=-20)
|
||||
config._clamp()
|
||||
assert config.pitch == -12
|
||||
|
||||
def test_clamp_pitch_high(self):
|
||||
config = TtsConfig(enabled=True, pitch=20)
|
||||
config._clamp()
|
||||
assert config.pitch == 12
|
||||
|
||||
def test_clamp_pitch_normal_unchanged(self):
|
||||
config = TtsConfig(enabled=True, pitch=5.0)
|
||||
config._clamp()
|
||||
assert config.pitch == 5.0
|
||||
|
||||
def test_clamp_volume_low(self):
|
||||
config = TtsConfig(enabled=True, volume=-1.0)
|
||||
config._clamp()
|
||||
assert config.volume == 0.0
|
||||
|
||||
def test_clamp_volume_high(self):
|
||||
config = TtsConfig(enabled=True, volume=2.0)
|
||||
config._clamp()
|
||||
assert config.volume == 1.0
|
||||
|
||||
def test_clamp_volume_normal_unchanged(self):
|
||||
config = TtsConfig(enabled=True, volume=0.5)
|
||||
config._clamp()
|
||||
assert config.volume == 0.5
|
||||
Executable
+280
@@ -0,0 +1,280 @@
|
||||
"""VerificationCode 单元测试."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from domain.verification_code import VerificationCode
|
||||
|
||||
|
||||
class TestVerificationCodeCreate:
|
||||
"""create() 工厂方法测试."""
|
||||
|
||||
def test_create_basic(self):
|
||||
vc = VerificationCode.create("test@example.com", "email_login")
|
||||
assert vc.id is not None
|
||||
assert len(vc.id) == 32
|
||||
assert vc.recipient == "test@example.com"
|
||||
assert vc.code_type == "email_login"
|
||||
assert len(vc.code) == 6
|
||||
assert vc.code.isdigit()
|
||||
assert vc.used_at is None
|
||||
assert vc.attempts == 0
|
||||
assert vc.created_at is not None
|
||||
assert vc.expires_at > vc.created_at
|
||||
|
||||
def test_create_recipient_stripped(self):
|
||||
vc = VerificationCode.create(" test@example.com ", "email_login")
|
||||
assert vc.recipient == "test@example.com"
|
||||
|
||||
def test_create_custom_code(self):
|
||||
vc = VerificationCode.create("test@example.com", "email_login", custom_code="123456")
|
||||
assert vc.code == "123456"
|
||||
|
||||
def test_create_custom_ttl(self):
|
||||
fixed_now = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
with patch("domain.verification_code.datetime") as mock_dt:
|
||||
mock_dt.now.return_value = fixed_now
|
||||
mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
|
||||
vc = VerificationCode.create("test@example.com", "email_login", ttl_seconds=60)
|
||||
assert vc.expires_at == fixed_now + timedelta(seconds=60)
|
||||
|
||||
def test_create_default_ttl_300(self):
|
||||
fixed_now = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
with patch("domain.verification_code.datetime") as mock_dt:
|
||||
mock_dt.now.return_value = fixed_now
|
||||
mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
|
||||
vc = VerificationCode.create("test@example.com", "email_login")
|
||||
assert vc.expires_at == fixed_now + timedelta(seconds=300)
|
||||
|
||||
def test_create_unique_ids(self):
|
||||
vc1 = VerificationCode.create("a@b.com", "email_login")
|
||||
vc2 = VerificationCode.create("a@b.com", "email_login")
|
||||
assert vc1.id != vc2.id
|
||||
|
||||
def test_create_unique_codes(self):
|
||||
codes = set()
|
||||
for _ in range(20):
|
||||
vc = VerificationCode.create("a@b.com", "email_login")
|
||||
codes.add(vc.code)
|
||||
# 20个随机6位码几乎肯定不都一样
|
||||
assert len(codes) > 1
|
||||
|
||||
def test_create_phone_recipient(self):
|
||||
vc = VerificationCode.create("13800138000", "phone_login")
|
||||
assert vc.recipient == "13800138000"
|
||||
assert vc.code_type == "phone_login"
|
||||
|
||||
def test_create_all_code_types(self):
|
||||
for ct in ["email_bind", "phone_bind", "email_login", "phone_login", "reset_password"]:
|
||||
vc = VerificationCode.create("test@example.com", ct)
|
||||
assert vc.code_type == ct
|
||||
|
||||
|
||||
class TestVerificationCodeIsExpired:
|
||||
"""is_expired 属性测试."""
|
||||
|
||||
def test_not_expired_future(self):
|
||||
future = datetime.now(timezone.utc) + timedelta(hours=1)
|
||||
vc = VerificationCode(
|
||||
id="1",
|
||||
recipient="a@b.com",
|
||||
code="123456",
|
||||
code_type="email_login",
|
||||
expires_at=future,
|
||||
)
|
||||
assert vc.is_expired is False
|
||||
|
||||
def test_expired_past(self):
|
||||
past = datetime.now(timezone.utc) - timedelta(hours=1)
|
||||
vc = VerificationCode(
|
||||
id="1",
|
||||
recipient="a@b.com",
|
||||
code="123456",
|
||||
code_type="email_login",
|
||||
expires_at=past,
|
||||
)
|
||||
assert vc.is_expired is True
|
||||
|
||||
def test_expired_boundary_exact(self):
|
||||
# 用mock固定时间,expires_at等于当前时间不算过期
|
||||
fixed_now = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
with patch("domain.verification_code.datetime") as mock_dt:
|
||||
mock_dt.now.return_value = fixed_now
|
||||
mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
|
||||
vc = VerificationCode(
|
||||
id="1",
|
||||
recipient="a@b.com",
|
||||
code="123456",
|
||||
code_type="email_login",
|
||||
expires_at=fixed_now,
|
||||
)
|
||||
assert vc.is_expired is False
|
||||
|
||||
|
||||
class TestVerificationCodeIsUsed:
|
||||
"""is_used 属性测试."""
|
||||
|
||||
def test_not_used_default(self):
|
||||
vc = VerificationCode.create("a@b.com", "email_login")
|
||||
assert vc.is_used is False
|
||||
|
||||
def test_is_used_after_mark(self):
|
||||
vc = VerificationCode.create("a@b.com", "email_login")
|
||||
vc.mark_used()
|
||||
assert vc.is_used is True
|
||||
|
||||
|
||||
class TestVerificationCodeIsValid:
|
||||
"""is_valid 属性测试."""
|
||||
|
||||
def test_valid_fresh(self):
|
||||
future = datetime.now(timezone.utc) + timedelta(hours=1)
|
||||
vc = VerificationCode(
|
||||
id="1",
|
||||
recipient="a@b.com",
|
||||
code="123456",
|
||||
code_type="email_login",
|
||||
expires_at=future,
|
||||
)
|
||||
assert vc.is_valid is True
|
||||
|
||||
def test_invalid_expired(self):
|
||||
past = datetime.now(timezone.utc) - timedelta(hours=1)
|
||||
vc = VerificationCode(
|
||||
id="1",
|
||||
recipient="a@b.com",
|
||||
code="123456",
|
||||
code_type="email_login",
|
||||
expires_at=past,
|
||||
)
|
||||
assert vc.is_valid is False
|
||||
|
||||
def test_invalid_used(self):
|
||||
future = datetime.now(timezone.utc) + timedelta(hours=1)
|
||||
vc = VerificationCode(
|
||||
id="1",
|
||||
recipient="a@b.com",
|
||||
code="123456",
|
||||
code_type="email_login",
|
||||
expires_at=future,
|
||||
)
|
||||
vc.mark_used()
|
||||
assert vc.is_valid is False
|
||||
|
||||
def test_invalid_expired_and_used(self):
|
||||
past = datetime.now(timezone.utc) - timedelta(hours=1)
|
||||
vc = VerificationCode(
|
||||
id="1",
|
||||
recipient="a@b.com",
|
||||
code="123456",
|
||||
code_type="email_login",
|
||||
expires_at=past,
|
||||
)
|
||||
vc.mark_used()
|
||||
assert vc.is_valid is False
|
||||
|
||||
|
||||
class TestVerificationCodeMarkUsed:
|
||||
"""mark_used 方法测试."""
|
||||
|
||||
def test_mark_used_sets_timestamp(self):
|
||||
vc = VerificationCode.create("a@b.com", "email_login")
|
||||
assert vc.used_at is None
|
||||
before = datetime.now(timezone.utc)
|
||||
vc.mark_used()
|
||||
after = datetime.now(timezone.utc)
|
||||
assert vc.used_at is not None
|
||||
assert before <= vc.used_at <= after
|
||||
|
||||
def test_mark_used_twice_overwrites(self):
|
||||
vc = VerificationCode.create("a@b.com", "email_login")
|
||||
vc.mark_used()
|
||||
first = vc.used_at
|
||||
# 时间足够短,一般不会不同,但确保可以重复调用
|
||||
vc.mark_used()
|
||||
assert vc.used_at is not None
|
||||
|
||||
|
||||
class TestVerificationCodeIncrementAttempts:
|
||||
"""increment_attempts 方法测试."""
|
||||
|
||||
def test_default_zero(self):
|
||||
vc = VerificationCode.create("a@b.com", "email_login")
|
||||
assert vc.attempts == 0
|
||||
|
||||
def test_increment_once(self):
|
||||
vc = VerificationCode.create("a@b.com", "email_login")
|
||||
vc.increment_attempts()
|
||||
assert vc.attempts == 1
|
||||
|
||||
def test_increment_multiple(self):
|
||||
vc = VerificationCode.create("a@b.com", "email_login")
|
||||
for _i in range(5):
|
||||
vc.increment_attempts()
|
||||
assert vc.attempts == 5
|
||||
|
||||
|
||||
class TestVerificationCodeBasics:
|
||||
"""基础构造和 slots 测试."""
|
||||
|
||||
def test_direct_construction(self):
|
||||
now = datetime.now(timezone.utc)
|
||||
vc = VerificationCode(
|
||||
id="abc123",
|
||||
recipient="test@test.com",
|
||||
code="000000",
|
||||
code_type="email_bind",
|
||||
expires_at=now + timedelta(minutes=5),
|
||||
used_at=None,
|
||||
attempts=0,
|
||||
created_at=now,
|
||||
)
|
||||
assert vc.id == "abc123"
|
||||
assert vc.recipient == "test@test.com"
|
||||
assert vc.code == "000000"
|
||||
|
||||
def test_slots_no_extra_attrs(self):
|
||||
vc = VerificationCode.create("a@b.com", "email_login")
|
||||
with pytest.raises((AttributeError, TypeError)):
|
||||
vc.new_field = "value" # type: ignore[attr-defined]
|
||||
|
||||
def test_equality_same_id(self):
|
||||
now = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
vc1 = VerificationCode(
|
||||
id="same",
|
||||
recipient="a@b.com",
|
||||
code="111",
|
||||
code_type="email_login",
|
||||
expires_at=now,
|
||||
created_at=now,
|
||||
)
|
||||
vc2 = VerificationCode(
|
||||
id="same",
|
||||
recipient="a@b.com",
|
||||
code="111",
|
||||
code_type="email_login",
|
||||
expires_at=now,
|
||||
created_at=now,
|
||||
)
|
||||
assert vc1 == vc2
|
||||
|
||||
def test_equality_different_id(self):
|
||||
now = datetime.now(timezone.utc)
|
||||
vc1 = VerificationCode(
|
||||
id="id1",
|
||||
recipient="a@b.com",
|
||||
code="111",
|
||||
code_type="email_login",
|
||||
expires_at=now,
|
||||
)
|
||||
vc2 = VerificationCode(
|
||||
id="id2",
|
||||
recipient="a@b.com",
|
||||
code="111",
|
||||
code_type="email_login",
|
||||
expires_at=now,
|
||||
)
|
||||
assert vc1 != vc2
|
||||
Reference in New Issue
Block a user