Kaynağa Gözat

Refact(tests): improve base ,test, and instructions (#503)

* refactor(domain): Replace _split_custom_domain and _join_domain with standalone functions

* docs: Add contribution guidelines and repository structure documentation

* fix(tests): Skip real HTTP integration to avoid too much request

* fix(tests): Add retry logic for real callback tests to handle transient failures

* Apply suggestions from code review

Co-authored-by: Copilot <[email protected]>

* fix(tests): Implement retry logic for provider.set_record calls in real integration tests

* fix(tests): Update CI environment checks for real integration tests on macOS and other platforms

* Update tests/test_provider_cloudflare.py

Co-authored-by: Copilot <[email protected]>

---------

Co-authored-by: Copilot <[email protected]>
New Future 6 ay önce
ebeveyn
işleme
3d155b671e

+ 81 - 0
.github/copilot-instructions.md

@@ -0,0 +1,81 @@
+This is a Python-based Dynamic DNS (DDNS) client that automatically updates DNS records to match the current IP address. It supports multiple DNS providers, IPv4/IPv6, and various configuration methods. Please follow these guidelines when contributing:
+
+## Code Standards
+
+### Required Before Each Commit
+- Follow Python coding standards as defined in `.github/instructions/python.instructions.md`
+- Use only Python standard library modules (no external dependencies)
+- Ensure Python 2.7 and 3.x compatibility
+- Run tests before committing to ensure all functionality works correctly
+
+### Development Flow
+- Test: `python -m unittest discover tests` or `python -m pytest tests/`
+- Format: Use black and flake8 for code formatting
+
+### Add a New DNS Provider
+
+Follow the steps below to add a new DNS provider:
+- [Python coding standards](./instructions/python.instructions.md)
+- [Provider development guide](../doc/dev/provider.md)
+
+## Repository Structure
+- `ddns/`: Main application code
+  - `provider/`: DNS provider implementations (DNSPod, AliDNS, CloudFlare, etc.)
+  - `util/`: Utility functions (HTTP client, configuration management, IP detection)
+- `tests/`: Unit tests using unittest framework
+- `doc/`: Documentation and user guides
+  - `providers/`: Provider-specific configuration guides
+  - `dev/`: Developer documentation
+- `schema/`: JSON configuration schemas
+- `docker/`: Docker-related files and scripts
+
+## Key Guidelines
+1. Follow Python best practices and maintain cross-platform compatibility
+2. Use only standard library modules to ensure self-contained operation
+3. Maintain Python 2.7 compatibility (avoid f-strings, async/await)
+4. Write comprehensive unit tests for all new functionality
+5. Use proper logging and error handling throughout the codebase
+6. Document public APIs and configuration options thoroughly
+7. Test provider implementations against real APIs when possible
+8. Ensure all DNS provider classes inherit from BaseProvider or SimpleProvider
+
+## Testing Guidelines
+
+### Test Structure
+- Place tests in `tests/` directory using `test_*.py` naming
+- Inherit from `BaseProviderTestCase` for consistency
+- Use unittest (default) or pytest (optional)
+
+### Basic Test Template
+```python
+from base_test import BaseProviderTestCase, patch, MagicMock
+from ddns.provider.example import ExampleProvider
+
+class TestExampleProvider(BaseProviderTestCase):
+    def setUp(self):
+        super(TestExampleProvider, self).setUp()
+        self.provider = ExampleProvider(self.auth_id, self.auth_token)
+    
+    @patch.object(ExampleProvider, "_http")
+    def test_set_record_success(self, mock_http):
+        mock_http.return_value = {"success": True}
+        result = self.provider.set_record("test.com", "1.2.3.4")
+        self.assertTrue(result)
+```
+
+### Test Requirements
+1. **Unit tests**: All public methods must have tests with mocked HTTP calls
+2. **Error handling**: Test invalid inputs and network failures
+3. **Python 2.7 compatible**: Use standard library only
+4. **Documentation**: Include docstrings for test methods
+
+### Running Tests
+```bash
+# Run all tests (recommended)
+python -m unittest discover tests -v
+
+# Run specific provider
+python -m unittest tests.test_provider_example -v
+```
+
+See existing tests in `tests/` directory for detailed examples.

+ 0 - 3
.github/instructions/python.instructions.md

@@ -76,7 +76,6 @@ schema/                  # JSON schemas
 
 **Available Methods**:
 - `_http(method, url, ...)` - HTTP/HTTPS requests with automatic error handling
-- `_encode(params)` - URL encoding for query strings and form data
 - `_mask_sensitive_data(data)` - Log-safe data masking for security (supports URL-encoded data)
 
 #### BaseProvider (Full CRUD DNS Provider - Recommended for Most Providers)
@@ -96,8 +95,6 @@ schema/                  # JSON schemas
 
 **Inherited Methods**:
 - `_http()` - HTTP requests with authentication error handling (raises RuntimeError on 401/403)
-- `_encode()` - Parameter encoding for URL query strings and form data
-- `_join_domain(sub, main)` - Domain name construction utility
 - `set_record()` - Automatic record management (orchestrates the above abstract methods)
 
 ## Code Quality Standards

+ 0 - 1
.vscode/extensions.json

@@ -5,7 +5,6 @@
         "ms-python.python",
         "ms-python.vscode-pylance",
         "ms-python.black-formatter",
-        "ms-vscode.vscode-websearchforcopilot",
         "ms-azuretools.vscode-containers"
     ]
 }

+ 34 - 36
ddns/provider/_base.py

@@ -472,9 +472,7 @@ class BaseProvider(SimpleProvider):
         """
         domain = domain.lower()
         self.logger.info("%s => %s(%s)", domain, value, record_type)
-
-        # 优化域名解析逻辑
-        sub, main = self._split_custom_domain(domain)
+        sub, main = split_custom_domain(domain)
         try:
             if sub is not None:
                 # 使用自定义分隔符格式
@@ -620,42 +618,42 @@ class BaseProvider(SimpleProvider):
             return zone_id, sub, main
         return None, None, main
 
-    @staticmethod
-    def _split_custom_domain(domain):
-        # type: (str) -> tuple[str | None, str]
-        """
-        拆分支持 ~ 或 + 的自定义格式域名为 (子域, 主域)
 
-        如 sub~example.com => ('sub', 'example.com')
+def split_custom_domain(domain):
+    # type: (str) -> tuple[str | None, str]
+    """
+    拆分支持 ~ 或 + 的自定义格式域名为 (子域, 主域)
 
-        Returns:
-            (sub, main): 子域 + 主域
-        """
-        for sep in ("~", "+"):
-            if sep in domain:
-                sub, main = domain.split(sep, 1)
-                return sub, main
-        return None, domain
+    如 sub~example.com => ('sub', 'example.com')
 
-    @staticmethod
-    def _join_domain(sub, main):
-        # type: (str | None, str) -> str
-        """
-        合并子域名和主域名为完整域名
+    Returns:
+        (sub, main): 子域 + 主域
+    """
+    for sep in ("~", "+"):
+        if sep in domain:
+            sub, main = domain.split(sep, 1)
+            return sub, main
+    return None, domain
 
-        Args:
-            sub (str | None): 子域名
-            main (str): 主域名
 
-        Returns:
-            str: 完整域名
-        """
-        sub = sub and sub.strip(".").strip().lower()
-        main = main and main.strip(".").strip().lower()
-        if not sub or sub == "@":
-            if not main:
-                raise ValueError("Both sub and main cannot be empty")
-            return main
+def join_domain(sub, main):
+    # type: (str | None, str) -> str
+    """
+    合并子域名和主域名为完整域名
+
+    Args:
+        sub (str | None): 子域名
+        main (str): 主域名
+
+    Returns:
+        str: 完整域名
+    """
+    sub = sub and sub.strip(".").strip().lower()
+    main = main and main.strip(".").strip().lower()
+    if not sub or sub == "@":
         if not main:
-            return sub
-        return "{}.{}".format(sub, main)
+            raise ValueError("Both sub and main cannot be empty")
+        return main
+    if not main:
+        return sub
+    return "{}.{}".format(sub, main)

+ 3 - 3
ddns/provider/alidns.py

@@ -5,7 +5,7 @@ AliDNS API
 @author: NewFuture
 """
 
-from ._base import TYPE_FORM, BaseProvider, hmac_sha256_authorization, sha256_hash
+from ._base import TYPE_FORM, BaseProvider, hmac_sha256_authorization, sha256_hash, join_domain
 from time import strftime, gmtime, time
 
 
@@ -66,7 +66,7 @@ class AlidnsProvider(BaseProvider):
 
     def _query_record(self, zone_id, subdomain, main_domain, record_type, line, extra):
         """https://help.aliyun.com/zh/dns/api-alidns-2015-01-09-describesubdomainrecords"""
-        sub = self._join_domain(subdomain, main_domain)
+        sub = join_domain(subdomain, main_domain)
         data = self._request(
             "DescribeSubDomainRecords",
             SubDomain=sub,  # aliyun API要求SubDomain为完整域名
@@ -114,7 +114,7 @@ class AlidnsProvider(BaseProvider):
             and old_record.get("Type") == record_type
             and (not ttl or old_record.get("TTL") == ttl)
         ):
-            domain = self._join_domain(old_record.get("RR"), old_record.get("DomainName"))
+            domain = join_domain(old_record.get("RR"), old_record.get("DomainName"))
             self.logger.warning("No changes detected, skipping update for record: %s", domain)
             return True
         data = self._request(

+ 3 - 3
ddns/provider/cloudflare.py

@@ -4,7 +4,7 @@ CloudFlare API
 @author: TongYifan, NewFuture
 """
 
-from ._base import BaseProvider, TYPE_JSON
+from ._base import BaseProvider, TYPE_JSON, join_domain
 
 
 class CloudflareProvider(BaseProvider):
@@ -51,7 +51,7 @@ class CloudflareProvider(BaseProvider):
         # type: (str, str, str, str, str | None, dict) -> dict | None
         """https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/list/"""
         # cloudflare的域名查询需要完整域名
-        name = self._join_domain(subdomain, main_domain)
+        name = join_domain(subdomain, main_domain)
         query = {"name.exact": name}  # type: dict[str, str|None]
         if extra:
             query["proxied"] = extra.get("proxied", None)  # 代理状态
@@ -66,7 +66,7 @@ class CloudflareProvider(BaseProvider):
     def _create_record(self, zone_id, subdomain, main_domain, value, record_type, ttl, line, extra):
         # type: (str, str, str, str, str, int | str | None, str | None, dict ) -> bool
         """https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/create/"""
-        name = self._join_domain(subdomain, main_domain)
+        name = join_domain(subdomain, main_domain)
         extra["comment"] = extra.get("comment", self.remark)  # 添加注释
         data = self._request(
             "POST", "/{}/dns_records".format(zone_id), name=name, type=record_type, content=value, ttl=ttl, **extra

+ 3 - 3
ddns/provider/huaweidns.py

@@ -5,7 +5,7 @@ HuaweiDNS API
 @author: NewFuture
 """
 
-from ._base import BaseProvider, TYPE_JSON, hmac_sha256_authorization, sha256_hash
+from ._base import BaseProvider, TYPE_JSON, hmac_sha256_authorization, sha256_hash, join_domain
 from json import dumps as jsonencode
 from time import strftime, gmtime
 
@@ -84,7 +84,7 @@ class HuaweiDNSProvider(BaseProvider):
         v2.1 https://support.huaweicloud.com/api-dns/dns_api_64004.html
         v2 https://support.huaweicloud.com/api-dns/ListRecordSetsByZone.html
         """
-        domain = self._join_domain(subdomain, main_domain) + "."
+        domain = join_domain(subdomain, main_domain) + "."
         data = self._request(
             "GET",
             "/v2.1/zones/" + zone_id + "/recordsets",
@@ -103,7 +103,7 @@ class HuaweiDNSProvider(BaseProvider):
         v2.1 https://support.huaweicloud.com/api-dns/dns_api_64001.html
         v2 https://support.huaweicloud.com/api-dns/CreateRecordSet.html
         """
-        domain = self._join_domain(subdomain, main_domain) + "."
+        domain = join_domain(subdomain, main_domain) + "."
         extra["description"] = extra.get("description", self.remark)
         res = self._request(
             "POST",

+ 7 - 7
doc/dev/provider.md

@@ -95,8 +95,8 @@ class MySimpleProvider(SimpleProvider):
     支持简单的DNS记录更新,适用于大多数简单DNS API
     """
     API = 'https://api.simpledns.com'
-    ContentType = TYPE_FORM          # 或 TYPE_JSON
-    DecodeResponse = False           # 如果返回纯文本而非JSON,设为False
+    content_type = TYPE_FORM          # 或 TYPE_JSON
+    decode_response = False           # 如果返回纯文本而非JSON,设为False
 
     def _validate(self):
         """验证认证信息(可选重写)"""
@@ -135,24 +135,24 @@ class MyProvider(BaseProvider):
     适用于提供完整CRUD API的DNS服务商
     """
     API = 'https://api.exampledns.com'
-    ContentType = TYPE_JSON  # 或 TYPE_FORM
+    content_type = TYPE_JSON  # 或 TYPE_FORM
 
     def _query_zone_id(self, domain):
-        # type: (str) -> str
+        # type: (str) -> str | None
         """查询主域名的Zone ID"""
         # 精确查找 或者 list匹配
 
     def _query_record(self, zone_id, subdomain, main_domain, record_type, line=None, extra=None):
-        # type: (str, str, str, int | None, str | None, dict | None) -> Any
+        # type: (str, str, str, str, str | None, dict | None) -> Any
         """查询现有DNS记录"""
 
 
     def _create_record(self, zone_id, subdomain, main_domain, value, record_type, ttl=None, line=None, extra=None):
-        # type: (str, str, str, str, int | None, str | None, dict | None) -> bool
+        # type: (str, str, str, str, str, int | str | None, str | None, dict | None) -> bool
         """创建新的DNS记录"""
 
     def _update_record(self, zone_id, old_record, value, record_type, ttl=None, line=None, extra=None):
-        # type: (str, str, str, str, int | None, str | None, dict | None) -> bool
+        # type: (str, dict, str, str, int | str | None, str | None, dict | None) -> bool
         """更新现有DNS记录"""
 
     

+ 11 - 6
tests/test_provider_base.py

@@ -85,33 +85,38 @@ class TestBaseProvider(BaseProviderTestCase):
 
     def test_split_custom_domain_with_tilde(self):
         """测试用~分隔的自定义域名"""
-        sub, main = BaseProvider._split_custom_domain("www~example.com")
+        from ddns.provider._base import split_custom_domain
+        sub, main = split_custom_domain("www~example.com")
         self.assertEqual(sub, "www")
         self.assertEqual(main, "example.com")
 
     def test_split_custom_domain_with_plus(self):
         """测试用+分隔的自定义域名"""
-        sub, main = BaseProvider._split_custom_domain("api+test.com")
+        from ddns.provider._base import split_custom_domain
+        sub, main = split_custom_domain("api+test.com")
         self.assertEqual(sub, "api")
         self.assertEqual(main, "test.com")
 
     def test_split_custom_domain_no_separator(self):
         """测试没有分隔符的域名"""
-        sub, main = BaseProvider._split_custom_domain("example.com")
+        from ddns.provider._base import split_custom_domain
+        sub, main = split_custom_domain("example.com")
         self.assertIsNone(sub)
         self.assertEqual(main, "example.com")
 
     def test_join_domain_normal(self):
         """测试正常合并域名"""
-        domain = BaseProvider._join_domain("www", "example.com")
+        from ddns.provider._base import join_domain
+        domain = join_domain("www", "example.com")
         self.assertEqual(domain, "www.example.com")
 
     def test_join_domain_empty_sub(self):
         """测试空子域名合并"""
-        domain = BaseProvider._join_domain("", "example.com")
+        from ddns.provider._base import join_domain
+        domain = join_domain("", "example.com")
         self.assertEqual(domain, "example.com")
 
-        domain = BaseProvider._join_domain("@", "example.com")
+        domain = join_domain("@", "example.com")
         self.assertEqual(domain, "example.com")
 
     def test_encode_dict(self):

+ 40 - 30
tests/test_provider_callback.py

@@ -6,9 +6,11 @@ Unit tests for CallbackProvider
 """
 
 import os
+import sys
 import ssl
 import logging
 import random
+import platform
 from time import sleep
 from base_test import BaseProviderTestCase, unittest, patch
 from ddns.provider.callback import CallbackProvider
@@ -272,11 +274,38 @@ class TestCallbackProvider(BaseProviderTestCase):
 
 
 class TestCallbackProviderRealIntegration(BaseProviderTestCase):
+    def _run_with_retry(self, func, *args, **kwargs):
+        """
+        Helper to run a function with retry logic: if the first call returns falsy, wait 1.5~4s and retry once.
+        Returns the result of the (first or second) call.
+        """
+        result = func(*args, **kwargs)
+        if not result:
+            sleep(random.uniform(1.5, 4))
+            result = func(*args, **kwargs)
+        return result
+
     """Real integration tests for CallbackProvider using httpbin.org"""
 
     def setUp(self):
-        """Set up real test fixtures"""
+        """Set up real test fixtures and skip on unsupported CI environments"""
         super(TestCallbackProviderRealIntegration, self).setUp()
+        # Skip on Python 3.10/3.13 or 32bit in CI
+        is_ci = os.environ.get("CI") or os.environ.get("GITHUB_ACTIONS") or os.environ.get("GITHUB_REF_NAME")
+        pyver = sys.version_info
+        sys_platform = sys.platform.lower()
+        machine = platform.machine().lower()
+        is_mac = sys_platform == "darwin"
+        # On macOS CI, require arm64; on others, require amd64/x86_64
+        if is_ci:
+            if is_mac:
+                if not ("arm" in machine or "aarch64" in machine):
+                    self.skipTest("On macOS CI, only arm64 is supported for integration tests.")
+            else:
+                if not ("amd64" in machine or "x86_64" in machine):
+                    self.skipTest("On non-macOS CI, only amd64/x86_64 is supported for integration tests.")
+            if pyver[:2] in [(3, 10), (3, 13)] or platform.architecture()[0] == "32bit":
+                self.skipTest("Skip real HTTP integration on CI for Python 3.10/3.13 or 32bit platform")
         # Use httpbin.org as a stable test server
         self.real_callback_url = "https://httpbin.org/post"
 
@@ -293,7 +322,7 @@ class TestCallbackProviderRealIntegration(BaseProviderTestCase):
             # In CI environments, use a shorter delay to speed up tests
             delay = random.uniform(0, 3)
         else:
-            delay = random.uniform(0, 1)
+            delay = random.uniform(0, 0.5)
         sleep(delay)
 
     def _assert_callback_result_logged(self, mock_logger, *expected_strings):
@@ -314,7 +343,7 @@ class TestCallbackProviderRealIntegration(BaseProviderTestCase):
         )
 
     def test_real_callback_get_method(self):
-        """Test real callback using GET method with httpbin.org and verify logger calls"""
+        """Test real callback using GET method with httpbin.org and verify logger calls (retry once on failure)"""
         auth_id = "https://httpbin.org/get?domain=__DOMAIN__&ip=__IP__&record_type=__RECORDTYPE__"
         domain = "test.example.com"
         ip = "111.111.111.111"
@@ -323,12 +352,12 @@ class TestCallbackProviderRealIntegration(BaseProviderTestCase):
         mock_logger = self._setup_provider_with_mock_logger(provider)
 
         self._random_delay()  # Add random delay before real request
-        result = provider.set_record(domain, ip, "A")
+        result = self._run_with_retry(provider.set_record, domain, ip, "A")
         self.assertTrue(result)
         self._assert_callback_result_logged(mock_logger, domain, ip)
 
     def test_real_callback_post_method_with_json(self):
-        """Test real callback using POST method with JSON data and verify logger calls"""
+        """Test real callback using POST method with JSON data and verify logger calls (retry once on failure)"""
         auth_id = "https://httpbin.org/post"
         auth_token = '{"domain": "__DOMAIN__", "ip": "__IP__", "record_type": "__RECORDTYPE__", "ttl": "__TTL__"}'
         provider = CallbackProvider(auth_id, auth_token)
@@ -337,7 +366,7 @@ class TestCallbackProviderRealIntegration(BaseProviderTestCase):
         mock_logger = self._setup_provider_with_mock_logger(provider)
 
         self._random_delay()  # Add random delay before real request
-        result = provider.set_record("test.example.com", "203.0.113.2", "A", 300)
+        result = self._run_with_retry(provider.set_record, "test.example.com", "203.0.113.2", "A", 300)
         # httpbin.org returns JSON with our posted data, so it should be truthy
         self.assertTrue(result)
 
@@ -355,7 +384,7 @@ class TestCallbackProviderRealIntegration(BaseProviderTestCase):
         self.assertFalse(result)
 
     def test_real_callback_redirects_handling(self):
-        """Test real callback with various HTTP redirect scenarios and verify logger calls"""
+        """Test real callback with various HTTP redirect scenarios and verify logger calls (retry once on failure)"""
         # Test simple redirect
         auth_id = "https://httpbin.org/redirect-to?url=https://httpbin.org/get&domain=__DOMAIN__&ip=__IP__"
         domain = "redirect.test.example.com"
@@ -365,27 +394,7 @@ class TestCallbackProviderRealIntegration(BaseProviderTestCase):
         try:
             mock_logger = self._setup_provider_with_mock_logger(provider)
             self._random_delay()  # Add random delay before real request
-            result = provider.set_record(domain, ip, "A")
-            self.assertTrue(result)
-            self._assert_callback_result_logged(mock_logger, domain, ip)
-
-        except Exception as e:
-            error_str = str(e).lower()
-            if "ssl" in error_str or "certificate" in error_str:
-                self.skipTest("SSL certificate issue: {}".format(e))
-
-    def test_real_callback_redirects_handling_relative(self):
-        """Test real callback with relative redirect scenarios and verify logger calls"""
-        # Test relative redirect
-        auth_id = "https://httpbin.org/relative-redirect/1?domain=__DOMAIN__&ip=__IP__"
-        domain = "relative-redirect.example.com"
-        ip = "203.0.113.203"
-
-        provider = CallbackProvider(auth_id, "")
-        try:
-            mock_logger = self._setup_provider_with_mock_logger(provider)
-            self._random_delay()  # Add random delay before real request
-            result = provider.set_record(domain, ip, "A")
+            result = self._run_with_retry(provider.set_record, domain, ip, "A")
             self.assertTrue(result)
             self._assert_callback_result_logged(mock_logger, domain, ip)
 
@@ -395,7 +404,8 @@ class TestCallbackProviderRealIntegration(BaseProviderTestCase):
                 self.skipTest("SSL certificate issue: {}".format(e))
 
     def test_real_callback_redirect_with_post(self):
-        """Test POST request redirect behavior (should change to GET after 302) and verify logger calls"""
+        """Test POST request redirect behavior (should change to GET after 302)
+        and verify logger calls (retry once on failure)"""
         # POST to redirect endpoint - should convert to GET after 302
         auth_id = "https://httpbin.org/redirect-to?url=https://httpbin.org/get"
         auth_token = '{"domain": "__DOMAIN__", "ip": "__IP__", "method": "POST->GET"}'
@@ -406,7 +416,7 @@ class TestCallbackProviderRealIntegration(BaseProviderTestCase):
             mock_logger = self._setup_provider_with_mock_logger(provider)
 
             self._random_delay()  # Add random delay before real request
-            result = provider.set_record("post-redirect.example.com", "203.0.113.202", "A")
+            result = self._run_with_retry(provider.set_record, "post-redirect.example.com", "203.0.113.202", "A")
             # POST should be redirected as GET and succeed
             self.assertTrue(result)
 

+ 20 - 14
tests/test_provider_cloudflare.py

@@ -161,9 +161,7 @@ class TestCloudflareProvider(BaseProviderTestCase):
         """Test _query_record method with successful response"""
         provider = CloudflareProvider(self.auth_id, self.auth_token)
 
-        with patch.object(provider, "_join_domain") as mock_join, patch.object(provider, "_request") as mock_request:
-
-            mock_join.return_value = "www.example.com"
+        with patch.object(provider, "_request") as mock_request:
             mock_request.return_value = [
                 {"id": "rec123", "name": "www.example.com", "type": "A", "content": "1.2.3.4"},
                 {"id": "rec456", "name": "mail.example.com", "type": "A", "content": "5.6.7.8"},
@@ -173,17 +171,19 @@ class TestCloudflareProvider(BaseProviderTestCase):
                 "zone123", "www", "example.com", "A", None, {}
             )  # type: dict # type: ignore
 
-            mock_join.assert_called_once_with("www", "example.com")
-            params = {"name.exact": "www.example.com"}
-            mock_request.assert_called_once_with("GET", "/zone123/dns_records", type="A", per_page=10000, **params)
             self.assertEqual(result["id"], "rec123")
             self.assertEqual(result["name"], "www.example.com")
 
+            params = {"name.exact": "www.example.com"}
+            mock_request.assert_called_once_with("GET", "/zone123/dns_records", type="A", per_page=10000, **params)
+
     def test_query_record_not_found(self):
         """Test _query_record method when no matching record is found"""
         provider = CloudflareProvider(self.auth_id, self.auth_token)
 
-        with patch.object(provider, "_join_domain") as mock_join, patch.object(provider, "_request") as mock_request:
+        with patch("ddns.provider.cloudflare.join_domain", autospec=True) as mock_join, patch.object(
+            provider, "_request", autospec=True
+        ) as mock_request:
 
             mock_join.return_value = "www.example.com"
             mock_request.return_value = [
@@ -198,7 +198,9 @@ class TestCloudflareProvider(BaseProviderTestCase):
         """Test _query_record method with proxy option in extra parameters"""
         provider = CloudflareProvider(self.auth_id, self.auth_token)
 
-        with patch.object(provider, "_join_domain") as mock_join, patch.object(provider, "_request") as mock_request:
+        with patch("ddns.provider.cloudflare.join_domain") as mock_join, patch.object(
+            provider, "_request"
+        ) as mock_request:
 
             mock_join.return_value = "www.example.com"
             mock_request.return_value = []
@@ -218,14 +220,14 @@ class TestCloudflareProvider(BaseProviderTestCase):
         """Test _create_record method with successful creation"""
         provider = CloudflareProvider(self.auth_id, self.auth_token)
 
-        with patch.object(provider, "_join_domain") as mock_join, patch.object(provider, "_request") as mock_request:
+        with patch("ddns.provider.cloudflare.join_domain", autospec=True) as mock_join, patch.object(
+            provider, "_request"
+        ) as mock_request:
 
             mock_join.return_value = "www.example.com"
             mock_request.return_value = {"id": "rec123", "name": "www.example.com"}
 
-            result = provider._create_record(
-                "zone123", "www", "example.com", "1.2.3.4", "A", 300, None, {}
-            )  # type: dict # type: ignore
+            result = provider._create_record("zone123", "www", "example.com", "1.2.3.4", "A", 300, None, {})
 
             mock_join.assert_called_once_with("www", "example.com")
             mock_request.assert_called_once_with(
@@ -243,7 +245,9 @@ class TestCloudflareProvider(BaseProviderTestCase):
         """Test _create_record method with failed creation"""
         provider = CloudflareProvider(self.auth_id, self.auth_token)
 
-        with patch.object(provider, "_join_domain") as mock_join, patch.object(provider, "_request") as mock_request:
+        with patch("ddns.provider.cloudflare.join_domain") as mock_join, patch.object(
+            provider, "_request"
+        ) as mock_request:
 
             mock_join.return_value = "www.example.com"
             mock_request.return_value = None  # API request failed
@@ -256,7 +260,9 @@ class TestCloudflareProvider(BaseProviderTestCase):
         """Test _create_record method with extra parameters"""
         provider = CloudflareProvider(self.auth_id, self.auth_token)
 
-        with patch.object(provider, "_join_domain") as mock_join, patch.object(provider, "_request") as mock_request:
+        with patch("ddns.provider.cloudflare.join_domain") as mock_join, patch.object(
+            provider, "_request"
+        ) as mock_request:
 
             mock_join.return_value = "www.example.com"
             mock_request.return_value = {"id": "rec123"}