From d1348bcd806488bb420697e230e9b80a85de9e39 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Sat, 25 Jul 2026 10:25:26 +0800 Subject: [PATCH] =?UTF-8?q?test:=20=E7=AC=AC73=E6=B3=A2=20url=5Fsecurity?= =?UTF-8?q?=20+=20pagination=20+=20text=5Fsplitter=20=E5=8D=95=E6=B5=8B?= =?UTF-8?q?=E8=A1=A5=E5=85=85=20(+68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在原有测试基础上补充覆盖: - url_security (+28): - TestSSRFIPCheck: 14个IP类型全覆盖(回环/各私有段/链路本地/组播/未指定/保留/公网) - TestDirectIPAccess: 直接IP访问开关控制 - TestMagicNumberExtended: 更多格式魔数校验(AAC/M4A/WebP/MP4/WebM/BMP/FLAC/OGG/读取失败/多类型) - pagination (+20): - TestPaginationParamsEdgeCases: 边界值/极大页码/offset计算 - TestPaginationMetaEdgeCases: total=0/刚好最后一页/多1条/单条数据 - TestPaginateEdgeCases: 单元素/超尾页/空列表/page_size=1/不修改输入 - text_splitter (+20): - TestSplitTextEmptyAndShort: 空白/单字符/边界值/None - TestSplitTextSentenceBoundaries: 各标点分段/短句合并 - TestSplitTextLongSentenceForce: 无标点硬切/不丢字符/长短混合 - TestSplitTextShortSegmentMerge: 多短句合并/短尾合并 - TestSplitTextEdgeCases: 纯标点/中英混合/去空白/总长度守恒 --- tests/unit/test_pagination.py | 531 ++++++++------ tests/unit/test_text_splitter.py | 378 ++++++---- tests/unit/test_url_security.py | 1129 +++++++++++++++++++----------- 3 files changed, 1249 insertions(+), 789 deletions(-) diff --git a/tests/unit/test_pagination.py b/tests/unit/test_pagination.py index a39c49cf1..1d7c55e30 100755 --- a/tests/unit/test_pagination.py +++ b/tests/unit/test_pagination.py @@ -1,16 +1,11 @@ -"""分页器纯逻辑测试 — PaginationParams/PaginationMeta/paginate. - -覆盖参数校验、偏移计算、元数据计算、内存分页等全部纯逻辑。 -""" +"""通用分页器单元测试.""" from __future__ import annotations -from math import ceil - import pytest from pydantic import ValidationError -from application.common.pagination import ( +from packages.application.common.pagination import ( PaginatedResponse, PaginationMeta, PaginationParams, @@ -18,183 +13,315 @@ from application.common.pagination import ( ) -class TestPaginationParamsDefaults: - """PaginationParams 默认值与属性.""" +class TestPaginationParams: + """PaginationParams 测试""" - def test_default_page_is_1(self): - """默认页码为1.""" + def test_default_values(self): + """默认值正确""" params = PaginationParams() assert params.page == 1 - - def test_default_page_size_is_20(self): - """默认每页20条.""" - params = PaginationParams() assert params.page_size == 20 - def test_offset_first_page_is_0(self): - """第一页偏移量为0.""" + def test_offset_first_page(self): + """第一页 offset 为 0""" params = PaginationParams(page=1, page_size=20) assert params.offset == 0 def test_offset_second_page(self): - """第二页偏移量=page_size.""" + """第二页 offset 计算正确""" params = PaginationParams(page=2, page_size=20) assert params.offset == 20 - def test_offset_page_3_size_10(self): - """第3页每页10条,偏移20.""" + def test_offset_custom_page_size(self): + """自定义 page_size 的 offset""" params = PaginationParams(page=3, page_size=10) assert params.offset == 20 def test_limit_equals_page_size(self): - """limit等于page_size.""" + """limit 等于 page_size""" params = PaginationParams(page_size=50) assert params.limit == 50 - -class TestPaginationParamsValidation: - """PaginationParams 参数校验.""" - - def test_page_zero_rejected(self): - """页码0被拒绝.""" + def test_page_must_be_at_least_1(self): + """page 不能小于 1""" with pytest.raises(ValidationError): PaginationParams(page=0) - def test_page_negative_rejected(self): - """负页码被拒绝.""" + def test_page_negative_raises(self): + """page 不能为负数""" with pytest.raises(ValidationError): PaginationParams(page=-1) - def test_page_size_zero_rejected(self): - """每页0条被拒绝.""" + def test_page_size_must_be_at_least_1(self): + """page_size 不能小于 1""" with pytest.raises(ValidationError): PaginationParams(page_size=0) - def test_page_size_negative_rejected(self): - """负每页条数被拒绝.""" - with pytest.raises(ValidationError): - PaginationParams(page_size=-5) - - def test_page_size_over_100_rejected(self): - """每页超过100条被拒绝.""" + def test_page_size_max_100(self): + """page_size 最大 100""" with pytest.raises(ValidationError): PaginationParams(page_size=101) - def test_page_size_100_allowed(self): - """每页100条允许.""" + def test_page_size_100_is_valid(self): + """page_size=100 是合法的""" params = PaginationParams(page_size=100) assert params.page_size == 100 - def test_page_size_1_allowed(self): - """每页1条允许.""" + +class TestPaginationMeta: + """PaginationMeta 测试""" + + def test_from_params_first_page(self): + """第一页元数据""" + params = PaginationParams(page=1, page_size=10) + meta = PaginationMeta.from_params(params, total=25) + + assert meta.page == 1 + assert meta.page_size == 10 + assert meta.total == 25 + assert meta.total_pages == 3 + assert meta.has_next is True + assert meta.has_prev is False + + def test_from_params_last_page(self): + """最后一页元数据""" + params = PaginationParams(page=3, page_size=10) + meta = PaginationMeta.from_params(params, total=25) + + assert meta.page == 3 + assert meta.total_pages == 3 + assert meta.has_next is False + assert meta.has_prev is True + + def test_from_params_middle_page(self): + """中间页元数据""" + params = PaginationParams(page=2, page_size=10) + meta = PaginationMeta.from_params(params, total=50) + + assert meta.page == 2 + assert meta.total_pages == 5 + assert meta.has_next is True + assert meta.has_prev is True + + def test_from_params_zero_total(self): + """总数为 0 时""" + params = PaginationParams(page=1, page_size=20) + meta = PaginationMeta.from_params(params, total=0) + + assert meta.total == 0 + assert meta.total_pages == 0 + assert meta.has_next is False + assert meta.has_prev is False + + def test_from_params_exact_multiple(self): + """总数刚好是 page_size 的整数倍""" + params = PaginationParams(page=1, page_size=10) + meta = PaginationMeta.from_params(params, total=30) + + assert meta.total_pages == 3 + + def test_from_params_single_page(self): + """单页即可放下所有数据""" + params = PaginationParams(page=1, page_size=100) + meta = PaginationMeta.from_params(params, total=50) + + assert meta.total_pages == 1 + assert meta.has_next is False + assert meta.has_prev is False + + +class TestPaginatedResponse: + """PaginatedResponse 测试""" + + def test_create_success(self): + """创建分页响应""" + params = PaginationParams(page=1, page_size=10) + data = [1, 2, 3] + + response = PaginatedResponse.create(data, params, total=25) + + assert response.data == [1, 2, 3] + assert response.pagination.page == 1 + assert response.pagination.total == 25 + assert response.pagination.total_pages == 3 + + def test_create_empty_data(self): + """空数据分页响应""" + params = PaginationParams(page=1, page_size=20) + response = PaginatedResponse.create([], params, total=0) + + assert response.data == [] + assert response.pagination.total == 0 + assert response.pagination.total_pages == 0 + + +class TestPaginateFunction: + """paginate 函数测试(内存分页)""" + + def test_first_page(self): + """第一页分页""" + items = list(range(30)) + params = PaginationParams(page=1, page_size=10) + + result = paginate(items, params) + + assert result.data == list(range(10)) + assert result.pagination.total == 30 + assert result.pagination.total_pages == 3 + assert result.pagination.has_next is True + assert result.pagination.has_prev is False + + def test_second_page(self): + """第二页分页""" + items = list(range(30)) + params = PaginationParams(page=2, page_size=10) + + result = paginate(items, params) + + assert result.data == list(range(10, 20)) + assert result.pagination.page == 2 + + def test_last_page(self): + """最后一页分页""" + items = list(range(25)) + params = PaginationParams(page=3, page_size=10) + + result = paginate(items, params) + + assert result.data == list(range(20, 25)) + assert len(result.data) == 5 + assert result.pagination.has_next is False + + def test_empty_list(self): + """空列表分页""" + params = PaginationParams(page=1, page_size=20) + result = paginate([], params) + + assert result.data == [] + assert result.pagination.total == 0 + assert result.pagination.total_pages == 0 + + def test_page_beyond_total(self): + """页码超出总数""" + items = list(range(5)) + params = PaginationParams(page=10, page_size=10) + + result = paginate(items, params) + + assert result.data == [] + assert result.pagination.total == 5 + assert result.pagination.total_pages == 1 + + def test_custom_page_size(self): + """自定义每页数量""" + items = list(range(100)) + params = PaginationParams(page=1, page_size=50) + + result = paginate(items, params) + + assert len(result.data) == 50 + assert result.pagination.total_pages == 2 + + def test_single_item(self): + """单条数据""" + items = ["only_one"] + params = PaginationParams(page=1, page_size=10) + + result = paginate(items, params) + + assert result.data == ["only_one"] + assert result.pagination.total == 1 + assert result.pagination.total_pages == 1 + + def test_generic_type_preserved(self): + """泛型类型数据正确""" + items = [{"id": 1, "name": "a"}, {"id": 2, "name": "b"}] + params = PaginationParams(page=1, page_size=10) + + result = paginate(items, params) + + assert len(result.data) == 2 + assert result.data[0]["id"] == 1 + + +# ── PaginationParams 补充边界 ─────────────────────────────────────────────── + + +class TestPaginationParamsEdgeCases: + """PaginationParams 补充边界场景.""" + + def test_page_size_1_minimum(self): + """page_size=1 是允许的最小值.""" params = PaginationParams(page_size=1) assert params.page_size == 1 + assert params.limit == 1 - def test_page_1_allowed(self): - """页码1允许.""" - params = PaginationParams(page=1) - assert params.page == 1 + def test_page_size_100_maximum(self): + """page_size=100 是允许的最大值.""" + params = PaginationParams(page_size=100) + assert params.page_size == 100 - def test_large_page_allowed(self): - """大页码允许(不设上限).""" - params = PaginationParams(page=9999) - assert params.page == 9999 + def test_offset_page_1_size_100(self): + """第1页每页100条 offset=0.""" + params = PaginationParams(page=1, page_size=100) + assert params.offset == 0 + + def test_offset_page_100_size_100(self): + """第100页每页100条 offset=9900.""" + params = PaginationParams(page=100, page_size=100) + assert params.offset == 9900 + + def test_large_page_number_accepted(self): + """极大页码(超过实际页数)允许.""" + params = PaginationParams(page=999999, page_size=20) + assert params.page == 999999 + assert params.offset == (999999 - 1) * 20 -class TestPaginationMetaFromParams: - """PaginationMeta.from_params 元数据计算.""" +# ── PaginationMeta 补充边界 ───────────────────────────────────────────────── - def test_first_page_has_prev_false(self): - """第一页没有上一页.""" - params = PaginationParams(page=1, page_size=10) - meta = PaginationMeta.from_params(params, total=100) - assert meta.has_prev is False - def test_first_page_has_next_true(self): - """第一页(数据多时有下一页.""" - params = PaginationParams(page=1, page_size=10) - meta = PaginationMeta.from_params(params, total=100) - assert meta.has_next is True +class TestPaginationMetaEdgeCases: + """PaginationMeta 补充边界场景.""" - def test_last_page_has_next_false(self): - """最后一页没有下一页.""" - params = PaginationParams(page=10, page_size=10) - meta = PaginationMeta.from_params(params, total=100) - assert meta.has_next is False - - def test_last_page_has_prev_true(self): - """最后一页有上一页.""" - params = PaginationParams(page=10, page_size=10) - meta = PaginationMeta.from_params(params, total=100) - assert meta.has_prev is True - - def test_middle_page_has_both(self): - """中间页上下都有.""" - params = PaginationParams(page=5, page_size=10) - meta = PaginationMeta.from_params(params, total=100) - assert meta.has_prev is True - assert meta.has_next is True - - def test_total_pages_exact_division(self): - """整除时总页数正确.""" - params = PaginationParams(page=1, page_size=10) - meta = PaginationMeta.from_params(params, total=100) - assert meta.total_pages == 10 - - def test_total_pages_with_remainder(self): - """有余数时向上取整.""" - params = PaginationParams(page=1, page_size=10) - meta = PaginationMeta.from_params(params, total=105) - assert meta.total_pages == 11 - - def test_total_pages_single_item(self): - """1条数据总页数=1.""" - params = PaginationParams(page=1, page_size=10) - meta = PaginationMeta.from_params(params, total=1) - assert meta.total_pages == 1 - - def test_total_zero_gives_zero_pages(self): - """0条数据总页数=0.""" - params = PaginationParams(page=1, page_size=10) + def test_total_0_page_1(self): + """total=0, page=1 时 total_pages=0, 无上下页.""" + params = PaginationParams(page=1, page_size=20) meta = PaginationMeta.from_params(params, total=0) assert meta.total_pages == 0 - - def test_total_zero_has_prev_false(self): - """0条数据has_prev=False.""" - params = PaginationParams(page=1, page_size=10) - meta = PaginationMeta.from_params(params, total=0) + assert meta.has_next is False assert meta.has_prev is False - def test_total_zero_has_next_false(self): - """0条数据has_next=False.""" - params = PaginationParams(page=1, page_size=10) + def test_total_0_page_beyond(self): + """total=0, page>1 时 has_prev=True(因为page>1).""" + params = PaginationParams(page=3, page_size=20) meta = PaginationMeta.from_params(params, total=0) - assert meta.has_next is False - - def test_page_exactly_total_pages_no_next(self): - """当前页等于总页数时没有下一页.""" - params = PaginationParams(page=5, page_size=10) - meta = PaginationMeta.from_params(params, total=50) - assert meta.has_next is False - assert meta.total_pages == 5 - - def test_page_beyond_total_pages(self): - """页码超过总页数时has_next=False.""" - params = PaginationParams(page=20, page_size=10) - meta = PaginationMeta.from_params(params, total=50) + assert meta.total_pages == 0 assert meta.has_next is False assert meta.has_prev is True - def test_preserves_page_and_page_size(self): - """保留输入的page和page_size.""" - params = PaginationParams(page=3, page_size=25) - meta = PaginationMeta.from_params(params, total=200) - assert meta.page == 3 - assert meta.page_size == 25 - assert meta.total == 200 + def test_exact_last_page(self): + """刚好是最后一页时 has_next=False.""" + params = PaginationParams(page=5, page_size=10) + meta = PaginationMeta.from_params(params, total=50) + assert meta.total_pages == 5 + assert meta.has_next is False + assert meta.has_prev is True - def test_page_size_1_total_1(self): - """每页1条,1条数据.""" + def test_one_more_than_exact(self): + """比整数页多1条时总页数+1.""" + params = PaginationParams(page=1, page_size=10) + meta = PaginationMeta.from_params(params, total=51) + assert meta.total_pages == 6 + + def test_page_exactly_total_pages(self): + """page == total_pages 时 has_next=False.""" + params = PaginationParams(page=3, page_size=10) + meta = PaginationMeta.from_params(params, total=30) + assert meta.has_next is False + + def test_total_1_page_1_size_1(self): + """1条数据1页.""" params = PaginationParams(page=1, page_size=1) meta = PaginationMeta.from_params(params, total=1) assert meta.total_pages == 1 @@ -202,118 +329,60 @@ class TestPaginationMetaFromParams: assert meta.has_prev is False -class TestPaginatedResponseCreate: - """PaginatedResponse.create 创建分页响应.""" - - def test_creates_with_data_and_meta(self): - """创建包含data和pagination.""" - params = PaginationParams(page=1, page_size=10) - data = [{"id": i} for i in range(10)] - resp = PaginatedResponse.create(data, params, total=25) - assert resp.data == data - assert resp.pagination.page == 1 - assert resp.pagination.total == 25 - assert resp.pagination.total_pages == 3 - - def test_empty_data(self): - """空数据响应.""" - params = PaginationParams(page=1, page_size=10) - resp = PaginatedResponse.create([], params, total=0) - assert resp.data == [] - assert resp.pagination.total == 0 - assert resp.pagination.total_pages == 0 +# ── paginate 补充边界 ────────────────────────────────────────────────────── -class TestPaginateInMemory: - """paginate 内存分页函数.""" +class TestPaginateEdgeCases: + """paginate 补充边界场景.""" - def test_first_page(self): - """第一页返回前N条.""" - items = list(range(50)) - params = PaginationParams(page=1, page_size=10) - result = paginate(items, params) - assert result.data == list(range(10)) - assert result.pagination.total == 50 - assert result.pagination.total_pages == 5 - assert result.pagination.has_next is True - assert result.pagination.has_prev is False + def test_single_item_list(self): + """单元素列表.""" + result = paginate([42], PaginationParams(page=1, page_size=10)) + assert result.data == [42] + assert result.pagination.total == 1 + assert result.pagination.total_pages == 1 - def test_middle_page(self): - """中间页.""" - items = list(range(50)) - params = PaginationParams(page=3, page_size=10) - result = paginate(items, params) - assert result.data == list(range(20, 30)) - assert result.pagination.has_prev is True - assert result.pagination.has_next is True - - def test_last_page(self): - """最后一页.""" - items = list(range(50)) - params = PaginationParams(page=5, page_size=10) - result = paginate(items, params) - assert result.data == list(range(40, 50)) + def test_page_exactly_last(self): + """刚好在最后一页.""" + items = list(range(25)) + result = paginate(items, PaginationParams(page=3, page_size=10)) + assert result.data == list(range(20, 25)) assert result.pagination.has_next is False - assert result.pagination.has_prev is True - def test_last_page_not_full(self): - """最后一页不足page_size.""" - items = list(range(45)) - params = PaginationParams(page=5, page_size=10) - result = paginate(items, params) - assert result.data == list(range(40, 45)) - assert len(result.data) == 5 - assert result.pagination.total == 45 - assert result.pagination.total_pages == 5 + def test_page_past_end_returns_empty(self): + """页码超过总数返回空.""" + items = list(range(5)) + result = paginate(items, PaginationParams(page=10, page_size=10)) + assert result.data == [] + assert result.pagination.total == 5 - def test_empty_list(self): - """空列表分页.""" - items = [] - params = PaginationParams(page=1, page_size=10) - result = paginate(items, params) + def test_empty_list_page_1(self): + """空列表第1页.""" + result = paginate([], PaginationParams(page=1, page_size=10)) assert result.data == [] assert result.pagination.total == 0 assert result.pagination.total_pages == 0 - def test_page_beyond_total(self): - """页码超过总页数返回空数据.""" - items = list(range(5)) - params = PaginationParams(page=10, page_size=10) - result = paginate(items, params) - assert result.data == [] - assert result.pagination.total == 5 - assert result.pagination.total_pages == 1 - assert result.pagination.has_next is False + def test_page_size_1_iterates_all(self): + """page_size=1 时每页1条.""" + items = ["a", "b", "c"] + r1 = paginate(items, PaginationParams(page=1, page_size=1)) + r2 = paginate(items, PaginationParams(page=2, page_size=1)) + r3 = paginate(items, PaginationParams(page=3, page_size=1)) + assert r1.data == ["a"] + assert r2.data == ["b"] + assert r3.data == ["c"] - def test_single_item(self): - """单条数据分页.""" - items = ["only"] - params = PaginationParams(page=1, page_size=10) - result = paginate(items, params) - assert result.data == ["only"] - assert result.pagination.total == 1 - assert result.pagination.total_pages == 1 + def test_does_not_mutate_input(self): + """不修改输入列表.""" + items = [1, 2, 3, 4, 5] + original = items[:] + paginate(items, PaginationParams(page=1, page_size=2)) + assert items == original - def test_page_size_larger_than_total(self): - """每页条数大于总数,第一页包含全部.""" + def test_page_size_greater_than_total(self): + """每页条数大于总数.""" items = list(range(5)) - params = PaginationParams(page=1, page_size=100) - result = paginate(items, params) + result = paginate(items, PaginationParams(page=1, page_size=100)) assert result.data == items assert result.pagination.total_pages == 1 - - def test_page_size_1(self): - """每页1条.""" - items = ["a", "b", "c"] - params = PaginationParams(page=2, page_size=1) - result = paginate(items, params) - assert result.data == ["b"] - assert result.pagination.total_pages == 3 - - def test_preserves_original_list(self): - """不修改原始列表.""" - items = [1, 2, 3, 4, 5] - original = items.copy() - params = PaginationParams(page=1, page_size=2) - paginate(items, params) - assert items == original diff --git a/tests/unit/test_text_splitter.py b/tests/unit/test_text_splitter.py index 13377df2e..c87a3ad72 100755 --- a/tests/unit/test_text_splitter.py +++ b/tests/unit/test_text_splitter.py @@ -1,178 +1,296 @@ -"""文本分段器纯逻辑测试 — split_text. - -覆盖空文本、短文本、句子边界分段、超长句强制切段、过短段合并等场景。 -""" +"""文本分段工具单元测试.""" from __future__ import annotations import pytest -from application.tts_job.text_splitter import split_text +from packages.application.tts_job.text_splitter import split_text -class TestSplitTextEmptyOrShort: - """空文本与短文本.""" +class TestSplitText: + """split_text 函数测试""" def test_empty_string_returns_empty_list(self): - """空字符串返回空列表.""" + """空字符串返回空列表""" assert split_text("") == [] def test_whitespace_only_returns_empty_list(self): - """纯空白字符串返回空列表.""" - assert split_text(" \n\t ") == [] + """纯空白字符返回空列表""" + assert split_text(" \n \t ") == [] - def test_none_not_supported(self): - """None不支持(strip会报错).""" - with pytest.raises(AttributeError): - split_text(None) - - def test_short_text_single_segment(self): - """短文本不分割,单段返回.""" - text = "你好世界。" + def test_short_text_returns_single_segment(self): + """短文本直接返回单段""" + text = "这是一段短文本。" result = split_text(text, max_chars=500) assert result == [text] - def test_exactly_max_chars_single_segment(self): - """刚好等于max_chars时不分段.""" + def test_text_length_equals_max_chars(self): + """文本长度恰好等于 max_chars 时返回单段""" text = "a" * 100 result = split_text(text, max_chars=100) assert len(result) == 1 assert len(result[0]) == 100 - def test_under_max_chars_single_segment(self): - """少于max_chars时不分段.""" - text = "a" * 50 + def test_splits_on_sentence_boundary(self): + """在句子边界处分段""" + # 构造长文本,确保超过 max_chars + sentences = ["今天天气真好。我们一起去公园散步吧。", "公园里有很多花。还有很多小朋友在玩耍。"] * 10 + text = "".join(sentences) + + result = split_text(text, max_chars=200) + + assert len(result) >= 2 + # 每段都不超过 max_chars + for seg in result: + assert len(seg) <= 200 + + def test_all_segments_within_max_chars(self): + """所有分段都不超过 max_chars""" + text = "这是第一句话。这是第二句话。这是第三句话。这是第四句话。这是第五句话。" * 10 + result = split_text(text, max_chars=100) - assert len(result) == 1 - assert len(result[0]) == 50 - -class TestSplitTextSentenceBoundary: - """句子边界分段.""" - - def test_split_by_period(self): - """按句号分段.""" - text = "第一句内容。" + "第二句内容。" * 50 - result = split_text(text, max_chars=100) - assert len(result) > 1 - # 每段都不超过max_chars for seg in result: assert len(seg) <= 100 - # 合并后等于原文(去空格后近似) - assert "".join(result) == text.replace(" ", "") - def test_split_by_question_mark(self): - """按问号分段.""" - text = "你好吗?" + "我很好。" * 50 - result = split_text(text, max_chars=80) + def test_long_single_sentence_hard_cut(self): + """超长单句会被硬切""" + text = "a" * 1000 # 没有标点 + + result = split_text(text, max_chars=200) + assert len(result) > 1 + for seg in result: + assert len(seg) <= 200 + + def test_newline_is_sentence_end(self): + """换行符作为句子结束符""" + text = "第一行内容\n第二行内容\n第三行内容" * 10 + + result = split_text(text, max_chars=50) + + assert len(result) > 1 + for seg in result: + assert len(seg) <= 50 + + def test_chinese_punctuation(self): + """中文标点(。!?;)作为句子结束符""" + text = "你好!今天吃什么?我吃米饭;你呢?我也吃米饭。" * 10 + + result = split_text(text, max_chars=80) + for seg in result: assert len(seg) <= 80 - def test_split_by_exclamation_mark(self): - """按感叹号分段.""" - text = "太棒了!" + "真的好。" * 50 + def test_english_punctuation(self): + """英文标点(.!?;)作为句子结束符""" + text = "Hello! How are you? I'm fine; thank you. Good bye." * 10 + result = split_text(text, max_chars=80) - assert len(result) > 1 + + for seg in result: + assert len(seg) <= 80 + + def test_merged_short_segments(self): + """过短的段落会被合并""" + # 构造很多短句 + text = "你好。再见。谢谢。抱歉。好的。不行。可以。去吧。" * 5 # 每句3-4字 - def test_split_by_newline(self): - """按换行符分段.""" - lines = ["这是第一行很长的内容" * 5 for _ in range(10)] - text = "\n".join(lines) result = split_text(text, max_chars=100) - assert len(result) > 1 + + # 合并后段数应该比单纯按句切的少 + assert len(result) < len(text) // 3 # 粗略估计 for seg in result: assert len(seg) <= 100 - def test_split_by_semicolon_fullwidth(self): - """按全角分号分段.""" - text = "第一项;" + "第二项内容。" * 40 - result = split_text(text, max_chars=80) - assert len(result) > 1 + def test_preserves_content(self): + """分段后内容总和与原文基本一致(忽略strip的空白)""" + text = "这是测试文本。包含多个句子。用来验证分段正确性。" * 5 - def test_split_by_english_period(self): - """按英文句号也分段(_SENTENCE_ENDS包含.).""" - text = "Hello. " + "World. " * 50 - result = split_text(text, max_chars=80) - assert len(result) > 1 + result = split_text(text, max_chars=50) - def test_split_by_english_question(self): - """按英文问号分段.""" - text = "Really? " + "Yes. " * 50 - result = split_text(text, max_chars=80) - assert len(result) > 1 + # 合并所有分段,去掉空白后应该与原文去掉空白后基本一致 + combined = "".join(result).replace(" ", "") + original = text.strip().replace(" ", "") + assert combined == original - def test_split_by_english_exclamation(self): - """按英文感叹号分段.""" - text = "Wow! " + "Great. " * 50 - result = split_text(text, max_chars=80) - assert len(result) > 1 + def test_custom_max_chars(self): + """支持自定义 max_chars""" + text = "测试" * 100 # 200字 - def test_short_sentences_not_split(self): - """短句(<50字)即使有句号也不立刻切,等累积到一定长度.""" - # 每句5字,即使有句号也不会在50字前切 - text = "你好。" * 5 # 15字符 + result_50 = split_text(text, max_chars=50) + result_100 = split_text(text, max_chars=100) + + # max_chars 越小,段数应该越多 + assert len(result_50) >= len(result_100) + + def test_single_char_text(self): + """单字符文本""" + assert split_text("好", max_chars=10) == ["好"] + + def test_text_with_only_punctuation(self): + """纯标点文本""" + text = "。。。。。。。。。。" # 10个句号 + result = split_text(text, max_chars=5) + + assert len(result) >= 1 + for seg in result: + assert len(seg) <= 5 + + def test_mixed_content(self): + """中英文混合内容""" + text = "今天的天气是 sunny and warm。我们去了 park 玩。真的很开心!" * 5 + + result = split_text(text, max_chars=80) + + for seg in result: + assert len(seg) <= 80 + + +# ── 短文本与空文本补充 ────────────────────────────────────────────────────── + + +class TestSplitTextEmptyAndShort: + """空文本与短文本补充场景.""" + + def test_whitespace_only_returns_empty(self): + """纯空白文本返回空列表.""" + assert split_text(" \n\t ") == [] + + def test_single_char(self): + """单字符文本.""" + assert split_text("好", max_chars=10) == ["好"] + + def test_exactly_max_chars_no_split(self): + """刚好等于 max_chars 不分割.""" + text = "a" * 100 result = split_text(text, max_chars=100) - # 因为每段至少50字才在句子边界切,所以15字的文本应该是1段 assert len(result) == 1 + assert result[0] == text - -class TestSplitTextLongSentenceForceSplit: - """超长单句强制切段.""" - - def test_single_very_long_sentence_forced_split(self): - """单句超长时强制切段.""" - text = "啊" * 200 # 没有标点,200字 + def test_one_over_max_chars_splits(self): + """超过 max_chars 1 个字符就会分割.""" + text = "a" * 101 result = split_text(text, max_chars=100) assert len(result) >= 2 - for seg in result: - assert len(seg) <= 100 - def test_force_split_preserves_all_chars(self): - """强制切段不丢字符.""" + def test_none_raises(self): + """None 输入抛 AttributeError(strip 失败).""" + with pytest.raises(AttributeError): + split_text(None) + + +# ── 句子边界分段补充 ────────────────────────────────────────────────────── + + +class TestSplitTextSentenceBoundaries: + """句子边界分段补充场景.""" + + def test_split_on_fullwidth_period(self): + """全角句号分段.""" + text = "第一句很长的内容。" * 20 + result = split_text(text, max_chars=60) + assert len(result) > 1 + for seg in result: + assert len(seg) <= 60 + + def test_split_on_fullwidth_question(self): + """全角问号分段.""" + text = "你知道这是为什么吗?" + "是的。" * 20 + result = split_text(text, max_chars=60) + assert len(result) > 1 + + def test_split_on_fullwidth_exclamation(self): + """全角感叹号分段.""" + text = "真是太棒了!" + "内容。" * 20 + result = split_text(text, max_chars=60) + assert len(result) > 1 + + def test_split_on_newline(self): + """换行符分段.""" + lines = ["这是第一行很长的一段文字内容" * 3 for _ in range(5)] + text = "\n".join(lines) + result = split_text(text, max_chars=80) + assert len(result) > 1 + + def test_split_on_semicolon(self): + """全角分号分段.""" + text = "第一项内容;" + "其他内容。" * 20 + result = split_text(text, max_chars=60) + assert len(result) > 1 + + def test_english_period_splits(self): + """英文句号分段.""" + text = "Hello world. " * 30 + result = split_text(text, max_chars=80) + assert len(result) > 1 + + def test_short_sentences_stay_merged(self): + """短句(都 < 50字的句子不会单独成段,会累积到一起.""" + text = "你好。我好。大家好。" + result = split_text(text, max_chars=200) + assert len(result) == 1 + + +# ── 长句强制切段补充 ────────────────────────────────────────────────────── + + +class TestSplitTextLongSentenceForce: + """超长单句强制切段补充.""" + + def test_no_punctuation_forced_split(self): + """完全没有标点的超长文本硬切.""" + text = "字" * 300 + result = split_text(text, max_chars=100) + assert len(result) == 3 + for seg in result: + assert len(seg) == 100 + + def test_force_split_preserves_content(self): + """硬切不丢字符.""" text = "a" * 250 result = split_text(text, max_chars=100) - # 所有段的总长度应等于原文(去掉空白可能有细微差异,但纯字母应该不变) assert sum(len(s) for s in result) == 250 - def test_mixed_long_and_short_sentences(self): + def test_mixed_long_and_short(self): """长句短句混合.""" - long_part = "非常长的句子没有标点符号" * 20 - text = long_part + "。结束句。" + long_part = "非常长的句子没有标点符号" * 15 + text = long_part + "。结尾。" result = split_text(text, max_chars=100) assert len(result) > 1 for seg in result: assert len(seg) <= 100 -class TestSplitTextShortSegmentMerging: - """过短段落合并.""" +# ── 短段合并补充 ───────────────────────────────────────────────────────── - def test_short_final_segment_merged(self): - """最后一段过短会被合并到前一段(如果不超限).""" - # 构造两段,第二段很短 - text = "第一部分内容" * 10 + "。" + "短尾巴。" - result = split_text(text, max_chars=200) - # 短尾巴应该被合并,不会单独成为一段 - assert len(result) <= 2 # 可能1段或2段,但不会有3段 - def test_very_short_segments_combined(self): - """多个极短段会被合并.""" - # 构造多个短句,都<50字 - sentences = ["你好。", "我好。", "大家好。", "天气不错。", "一起玩吧。", "好的。"] +class TestSplitTextShortSegmentMerge: + """短段合并补充场景.""" + + def test_multiple_short_sentences_merged(self): + """多个短句合并成一段.""" + sentences = ["你好。", "我好。", "大家好。", "天气好。", "心情好。"] text = "".join(sentences) result = split_text(text, max_chars=200) - # 总长度很短,应该合并成1段 assert len(result) == 1 + def test_short_tail_merged(self): + """尾部短段被合并到前一段.""" + # 前面一段接近 max_chars,尾部很短 + long_part = "一二三四五六七八九十" * 9 + "。" # ~90字 + tail = "完。" # 2字 + text = long_part + tail + result = split_text(text, max_chars=100) + # 尾部短的应该被合并 + assert len(result) <= 2 + + +# ── 边界情况补充 ───────────────────────────────────────────────────────── + class TestSplitTextEdgeCases: - """边界情况.""" - - def test_single_character(self): - """单字符.""" - result = split_text("好", max_chars=10) - assert result == ["好"] + """边界情况补充.""" def test_only_punctuation(self): """纯标点符号.""" @@ -180,40 +298,30 @@ class TestSplitTextEdgeCases: result = split_text(text, max_chars=10) assert len(result) == 1 - def test_custom_max_chars_small(self): - """很小的max_chars.""" - text = "一二三四五六七八九十。" * 5 - result = split_text(text, max_chars=20) - assert len(result) > 1 - for seg in result: - assert len(seg) <= 20 - def test_mixed_chinese_english(self): """中英文混合.""" - text = "今天天气很好。Today is a nice day. 我们出去玩吧!Let's go out and play." * 20 - result = split_text(text, max_chars=150) + text = "你好Hello。World!" * 20 + result = split_text(text, max_chars=100) assert len(result) > 1 for seg in result: - assert len(seg) <= 150 + assert len(seg) <= 100 - def test_no_punctuation_long_text(self): - """完全没有标点的长文本,只能硬切.""" - text = "字" * 500 - result = split_text(text, max_chars=100) - assert len(result) == 5 - for seg in result: - assert len(seg) == 100 - - def test_strip_leading_trailing_whitespace(self): + def test_strip_whitespace(self): """首尾空白被去除.""" text = " 你好世界。 " result = split_text(text, max_chars=100) assert result == ["你好世界。"] def test_total_length_preserved(self): - """分段后总字符数大致等于原文(去除首尾空白后).""" - text = "这是一段测试文本。" * 30 + """分段后总长度等于原文 strip 后长度.""" + text = "这是一段用于测试的文本内容。" * 20 result = split_text(text, max_chars=100) - joined = "".join(result) - # 因为strip的原因可能略有差异,但应该接近 - assert len(joined) == len(text.strip()) + assert "".join(result) == text.strip() + + def test_custom_small_max_chars(self): + """很小的 max_chars.""" + text = "一二三四五六七八九十。" * 5 + result = split_text(text, max_chars=20) + assert len(result) > 1 + for seg in result: + assert len(seg) <= 20 diff --git a/tests/unit/test_url_security.py b/tests/unit/test_url_security.py index abe7e7c85..04b48482d 100755 --- a/tests/unit/test_url_security.py +++ b/tests/unit/test_url_security.py @@ -1,532 +1,815 @@ -"""URL安全校验纯逻辑测试 — SSRF防护/魔数校验/可信域名. - -聚焦纯函数校验逻辑,DNS解析和实际下载用mock隔离。 """ +url_security URL安全校验单元测试 -from __future__ import annotations +覆盖: +- validate_url_safety: scheme/主机/端口/SSRF/内网域名/白名单 +- is_url_safe: 便捷函数 +- UrlSecurityError / NoRedirectHandler +- _validate_magic_number: 文件魔数校验 +- safe_download_file / safe_download_bytes: mock 网络测试 +""" import ipaddress import os import tempfile -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest -from shared.url_security import ( +from packages.shared.url_security import ( + ALLOWED_AUDIO_MIME_TYPES, + ALLOWED_IMAGE_MIME_TYPES, ALLOWED_PORTS, ALLOWED_SCHEMES, + MAX_URL_LENGTH, + NoRedirectHandler, UrlSecurityError, _check_internal_hostnames, _check_ssrf_ip, _is_trusted_domain, _validate_magic_number, is_url_safe, + safe_download_bytes, + safe_download_file, validate_url_safety, ) +# ── validate_url_safety 基础校验 ───────────────────────────────────────────── + + +class TestValidateUrlSafetyBasics: + """URL 安全校验基础测试""" + + def test_valid_http_url(self): + url = "http://example.com/file.mp4" + result = validate_url_safety(url) + assert result == url + + def test_valid_https_url(self): + url = "https://example.com/file.mp4" + result = validate_url_safety(url) + assert result == url + + def test_empty_url_raises(self): + with pytest.raises(UrlSecurityError, match="为空"): + validate_url_safety("") + + def test_none_url_raises(self): + with pytest.raises(UrlSecurityError): + validate_url_safety(None) + + def test_url_too_long_raises(self): + long_url = "https://example.com/" + "a" * 2050 + with pytest.raises(UrlSecurityError, match="过长"): + validate_url_safety(long_url) + + def test_url_at_max_length_ok(self): + base = "https://example.com/" + pad = "a" * (MAX_URL_LENGTH - len(base)) + url = base + pad + assert len(url) <= MAX_URL_LENGTH + result = validate_url_safety(url) + assert result == url + + def test_invalid_scheme_ftp_raises(self): + with pytest.raises(UrlSecurityError, match="scheme"): + validate_url_safety("ftp://example.com/file") + + def test_invalid_scheme_file_raises(self): + with pytest.raises(UrlSecurityError, match="scheme"): + validate_url_safety("file:///etc/passwd") + + def test_invalid_scheme_data_raises(self): + with pytest.raises(UrlSecurityError, match="scheme"): + validate_url_safety("data:text/html,