1212
1313import os
1414from pathlib import Path
15+ from typing import Optional
1516
1617import pytest
1718
1819from mfs_server .config import ServerConfig
19- from mfs_server .connectors .base import ConnectorPlugin , ObjectConfig
20+ from mfs_server .connectors .base import ConnectorConfigSchema , ConnectorPlugin , ObjectConfig
2021from mfs_server .connectors .file .plugin import FilePlugin
2122from mfs_server .connectors .github .plugin import GitHubPlugin
2223from mfs_server .connectors .registry import load_builtin , register
@@ -72,6 +73,31 @@ def preset_for(self, path: str): # noqa: ARG002
7273
7374register (_FakePlugin )
7475
76+
77+ # A fake plugin carrying a real CONFIG_SCHEMA, for TestValidateConfig. Mirrors
78+ # PostgresConfig's field shape (dsn / max_read_rows) but registers unconditionally --
79+ # unlike postgres, mysql, etc., whose registration is gated behind an optional SDK
80+ # (load_builtin() silently skips them when e.g. asyncpg isn't installed). Exercising
81+ # schema enforcement against a real optional-SDK connector made these tests pass or
82+ # fail depending on which extras happened to be installed, rather than on the
83+ # behavior under test.
84+ class _ValidateConfigSchema (ConnectorConfigSchema ):
85+ dsn : Optional [str ] = None
86+ max_read_rows : int = 100000
87+
88+
89+ class _FakeSchemaPlugin :
90+ URI_SCHEME = "mfs-fake-validate-config"
91+ CONFIG_SCHEMA = _ValidateConfigSchema
92+
93+ def __init__ (self , config , credential , ctx = None ):
94+ self .config = config
95+ self .credential = credential
96+ self .ctx = ctx
97+
98+
99+ register (_FakeSchemaPlugin )
100+
75101# ConnectorFactory.resolve_target dispatches via registry.get_plugin_cls, so the
76102# built-in connectors (file/github at minimum) must be registered before the
77103# resolve_target tests run. load_builtin() skips connectors whose optional SDK
@@ -306,37 +332,48 @@ def test_empty_values_accepted(self):
306332
307333
308334class TestValidateConfig :
335+ # Uses the fake "mfs-fake-validate-config" scheme registered above, not a real
336+ # connector like postgres -- postgres's own registration is gated behind the
337+ # optional asyncpg SDK (load_builtin() skips it silently when absent), so
338+ # exercising schema enforcement through it made these tests pass or fail
339+ # depending on which extras happened to be installed rather than on the
340+ # behavior under test. The fake schema mirrors PostgresConfig's field shape
341+ # (dsn / max_read_rows) so the cases below still cover the same 3 failure
342+ # shapes; postgres's real CONFIG_SCHEMA is unaffected and still enforced in
343+ # production, just not re-exercised here.
344+ CTYPE = "mfs-fake-validate-config"
345+
309346 def test_valid_config_passes (self , factory : ConnectorFactory ):
310- factory .validate_config ("postgres" , {"dsn" : "env:PG_DSN" , "max_read_rows" : 500 })
347+ factory .validate_config (self . CTYPE , {"dsn" : "env:PG_DSN" , "max_read_rows" : 500 })
311348
312349 def test_numeric_field_as_string_is_coerced_not_rejected (self , factory : ConnectorFactory ):
313350 # pydantic's default (lax) mode coerces an unambiguous numeric string to
314351 # int at the schema boundary -- the original bug wasn't that "100" is an
315352 # invalid max_read_rows, it's that nothing coerced it before an
316353 # internal `>` comparison crashed on a real string. This must NOT raise.
317- factory .validate_config ("postgres" , {"max_read_rows" : "100" })
354+ factory .validate_config (self . CTYPE , {"max_read_rows" : "100" })
318355
319356 def test_wrong_type_with_no_coercion_path_is_rejected (self , factory : ConnectorFactory ):
320357 # A bool has no sensible coercion to the str `dsn` expects -- unlike the
321358 # numeric-string case above, this should be a clean, named rejection
322- # instead of surfacing 'bool' object has no attribute 'decode' deep in
323- # asyncpg/urlparse .
359+ # instead of a raw exception surfacing deep in the plugin's own
360+ # connect()/read path .
324361 with pytest .raises (ValueError , match = "config_invalid.*dsn" ):
325- factory .validate_config ("postgres" , {"dsn" : True })
362+ factory .validate_config (self . CTYPE , {"dsn" : True })
326363
327364 def test_unrecognized_field_is_rejected (self , factory : ConnectorFactory ):
328365 with pytest .raises (ValueError , match = "config_invalid.*bogus_field" ):
329- factory .validate_config ("postgres" , {"dsn" : "env:PG_DSN" , "bogus_field" : "nonsense" })
366+ factory .validate_config (self . CTYPE , {"dsn" : "env:PG_DSN" , "bogus_field" : "nonsense" })
330367
331368 def test_credential_ref_and_legacy_alias_and_objects_are_allowed (
332369 self , factory : ConnectorFactory
333370 ):
334371 # Cross-cutting fields every connector config may carry regardless of
335372 # type -- declared once on ConnectorConfigSchema, not per-connector.
336- factory .validate_config ("postgres" , {"dsn" : "env:PG_DSN" , "credential_ref" : "env:X" })
337- factory .validate_config ("postgres" , {"dsn" : "env:PG_DSN" , "_credential_ref" : "env:X" })
373+ factory .validate_config (self . CTYPE , {"dsn" : "env:PG_DSN" , "credential_ref" : "env:X" })
374+ factory .validate_config (self . CTYPE , {"dsn" : "env:PG_DSN" , "_credential_ref" : "env:X" })
338375 factory .validate_config (
339- "postgres" , {"dsn" : "env:PG_DSN" , "objects" : [{"match" : "*.csv" , "priority" : 1 }]}
376+ self . CTYPE , {"dsn" : "env:PG_DSN" , "objects" : [{"match" : "*.csv" , "priority" : 1 }]}
340377 )
341378
342379 def test_connector_without_config_schema_is_unaffected (self , factory : ConnectorFactory ):
@@ -347,7 +384,7 @@ def test_connector_without_config_schema_is_unaffected(self, factory: ConnectorF
347384 factory .validate_config ("file" , {"root" : "/tmp/x" , "objects" : [{"match" : "*" }]})
348385
349386 def test_non_dict_config_is_a_noop (self , factory : ConnectorFactory ):
350- factory .validate_config ("postgres" , None )
387+ factory .validate_config (self . CTYPE , None )
351388
352389 def test_unknown_ctype_is_a_noop (self , factory : ConnectorFactory ):
353390 factory .validate_config ("no-such-connector-type" , {"anything" : 1 })
0 commit comments