simple-module-settings 0.0.39__tar.gz → 0.0.40__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/PKG-INFO +4 -4
  2. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/package.json +1 -1
  3. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/pyproject.toml +4 -4
  4. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/endpoints/module_api.py +2 -8
  5. simple_module_settings-0.0.40/settings/invalidation.py +70 -0
  6. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/module.py +14 -0
  7. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/pages/ModulesEdit.tsx +5 -1
  8. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/pages/components/ModuleForm.tsx +20 -8
  9. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/pages/components/TestConnectionButton.tsx +16 -4
  10. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/reload.py +11 -7
  11. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/store.py +4 -0
  12. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_reload.py +17 -1
  13. simple_module_settings-0.0.40/tests/test_settings_commit_invalidation.py +99 -0
  14. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_settings_secret_json_roundtrip.py +7 -0
  15. simple_module_settings-0.0.40/tests-js/settings_ModuleFormRevert.test.tsx +88 -0
  16. simple_module_settings-0.0.40/tests-js/settings_TestConnectionButton.test.tsx +111 -0
  17. simple_module_settings-0.0.39/tests-js/settings_TestConnectionButton.test.tsx +0 -47
  18. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/.gitignore +0 -0
  19. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/LICENSE +0 -0
  20. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/README.md +0 -0
  21. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/__init__.py +0 -0
  22. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/_announce.py +0 -0
  23. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/_field_meta.py +0 -0
  24. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/_listing.py +0 -0
  25. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/_managed_keys.py +0 -0
  26. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/_module_settings.py +0 -0
  27. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/_module_settings_props.py +0 -0
  28. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/_row_masking.py +0 -0
  29. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/_secrets.py +0 -0
  30. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/_unique_write.py +0 -0
  31. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/browse_query.py +0 -0
  32. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/cli.py +0 -0
  33. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/constants.py +0 -0
  34. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/contracts/__init__.py +0 -0
  35. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/contracts/accessor.py +0 -0
  36. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/contracts/events.py +0 -0
  37. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/contracts/invalidation.py +0 -0
  38. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/contracts/registry.py +0 -0
  39. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/contracts/schemas.py +0 -0
  40. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/deps.py +0 -0
  41. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/endpoints/__init__.py +0 -0
  42. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/endpoints/_create_form.py +0 -0
  43. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/endpoints/api.py +0 -0
  44. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/endpoints/tenant_api.py +0 -0
  45. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/endpoints/views.py +0 -0
  46. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/env_vars.py +0 -0
  47. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/errors.py +0 -0
  48. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/exceptions.py +0 -0
  49. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/hydrate.py +0 -0
  50. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/known_keys.py +0 -0
  51. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/locales/de.json +0 -0
  52. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/locales/en.json +0 -0
  53. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/models.py +0 -0
  54. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/module_registry.py +0 -0
  55. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/pages/Browse.tsx +0 -0
  56. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/pages/Create.tsx +0 -0
  57. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/pages/Edit.tsx +0 -0
  58. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/pages/api.generated.ts +0 -0
  59. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/pages/api.ts +0 -0
  60. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/pages/components/FieldInput.tsx +0 -0
  61. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/pages/components/FieldSource.tsx +0 -0
  62. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/pages/components/JsonInput.tsx +0 -0
  63. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/pages/components/KeyField.tsx +0 -0
  64. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/pages/components/ModuleFieldRow.tsx +0 -0
  65. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/pages/components/ResolvedValue.tsx +0 -0
  66. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/pages/components/ScopeTabs.tsx +0 -0
  67. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/pages/components/StoreCards.tsx +0 -0
  68. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/pages/components/StoreTable.tsx +0 -0
  69. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/pages/components/ValueInput.tsx +0 -0
  70. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/pages/components/last-test.ts +0 -0
  71. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/pages/components/validation-errors.test.ts +0 -0
  72. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/pages/components/validation-errors.ts +0 -0
  73. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/pages/routes.ts +0 -0
  74. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/pages/types.ts +0 -0
  75. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/py.typed +0 -0
  76. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/registration.py +0 -0
  77. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/service.py +0 -0
  78. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/services.py +0 -0
  79. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/settings.py +0 -0
  80. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/tenant_scope.py +0 -0
  81. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/settings/tenant_view.py +0 -0
  82. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_clear_route_per_scope.py +0 -0
  83. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_cli_import.py +0 -0
  84. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_concurrent_overrides.py +0 -0
  85. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_duplicate_and_race.py +0 -0
  86. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_env_pinned.py +0 -0
  87. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_hydrate.py +0 -0
  88. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_hydrate_related_fields.py +0 -0
  89. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_known_keys_meta.py +0 -0
  90. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_lenient_related_fields_validators.py +0 -0
  91. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_module_api.py +0 -0
  92. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_module_api_json_fields.py +0 -0
  93. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_module_registry.py +0 -0
  94. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_module_settings.py +0 -0
  95. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_module_settings_render.py +0 -0
  96. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_module_settings_serialize.py +0 -0
  97. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_registration.py +0 -0
  98. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_settings_accessor.py +0 -0
  99. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_settings_api.py +0 -0
  100. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_settings_default_clears_override.py +0 -0
  101. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_settings_domain_errors.py +0 -0
  102. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_settings_env_unprefixed.py +0 -0
  103. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_settings_events.py +0 -0
  104. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_settings_field_sources.py +0 -0
  105. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_settings_framework_reexports.py +0 -0
  106. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_settings_group_metadata.py +0 -0
  107. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_settings_host_restart_flags.py +0 -0
  108. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_settings_module.py +0 -0
  109. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_settings_schemas.py +0 -0
  110. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_settings_secret_masking.py +0 -0
  111. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_settings_secret_urls.py +0 -0
  112. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_settings_service.py +0 -0
  113. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_settings_typed_secret_masking.py +0 -0
  114. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_settings_view_authz.py +0 -0
  115. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_store.py +0 -0
  116. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_store_filters.py +0 -0
  117. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_store_secret_masking.py +0 -0
  118. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_tenant_settings.py +0 -0
  119. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_testable_checks.py +0 -0
  120. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests/test_upload_key_delete.py +0 -0
  121. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests-js/JsonFields.test.tsx +0 -0
  122. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests-js/ModuleForm.test.tsx +0 -0
  123. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests-js/ModulesEdit.test.tsx +0 -0
  124. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests-js/RestartHint.test.tsx +0 -0
  125. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tests-js/ScopeTabs.test.tsx +0 -0
  126. {simple_module_settings-0.0.39 → simple_module_settings-0.0.40}/tsconfig.json +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: simple_module_settings
3
- Version: 0.0.39
3
+ Version: 0.0.40
4
4
  Summary: Runtime settings UI — modules plug their own settings panels into a shared admin view
5
5
  Project-URL: Homepage, https://github.com/antosubash/simple_module_python
6
6
  Project-URL: Repository, https://github.com/antosubash/simple_module_python
@@ -21,9 +21,9 @@ Classifier: Topic :: Internet :: WWW/HTTP
21
21
  Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
22
22
  Classifier: Typing :: Typed
23
23
  Requires-Python: >=3.12
24
- Requires-Dist: simple-module-core==0.0.39
25
- Requires-Dist: simple-module-db==0.0.39
26
- Requires-Dist: simple-module-hosting==0.0.39
24
+ Requires-Dist: simple-module-core==0.0.40
25
+ Requires-Dist: simple-module-db==0.0.40
26
+ Requires-Dist: simple-module-hosting==0.0.40
27
27
  Requires-Dist: typer<0.28,>=0.12
28
28
  Description-Content-Type: text/markdown
29
29
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simple-module-py/settings",
3
- "version": "0.0.39",
3
+ "version": "0.0.40",
4
4
  "private": true,
5
5
  "description": "Frontend assets for the Settings module",
6
6
  "peerDependencies": {
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_settings"
3
- version = "0.0.39"
3
+ version = "0.0.40"
4
4
  description = "Runtime settings UI — modules plug their own settings panels into a shared admin view"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -21,9 +21,9 @@ classifiers = [
21
21
  "Typing :: Typed",
22
22
  ]
23
23
  dependencies = [
24
- "simple_module_core==0.0.39",
25
- "simple_module_db==0.0.39",
26
- "simple_module_hosting==0.0.39",
24
+ "simple_module_core==0.0.40",
25
+ "simple_module_db==0.0.40",
26
+ "simple_module_hosting==0.0.40",
27
27
  "typer>=0.12,<0.28",
28
28
  ]
29
29
 
@@ -12,7 +12,6 @@ from typing import Any
12
12
  from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request, Response, status
13
13
  from pydantic import ValidationError
14
14
  from simple_module_core.module_settings import module_settings_registry
15
- from simple_module_core.settings_hydrate import hydrate_settings
16
15
  from simple_module_hosting.i18n_deps import TranslatorDep
17
16
  from simple_module_hosting.permissions import RequiresPermission
18
17
 
@@ -23,8 +22,8 @@ from settings._module_settings import (
23
22
  )
24
23
  from settings._module_settings_props import serialize
25
24
  from settings.constants import PERM_DELETE, PERM_EDIT, PERM_VIEW
26
- from settings.contracts.events import SettingsReloaded
27
25
  from settings.deps import get_setting_service
26
+ from settings.invalidation import reload_on_commit
28
27
  from settings.reload import EnvPinnedError, apply_changes_and_reload
29
28
  from settings.service import SettingService
30
29
  from settings.store import SettingsStore
@@ -162,11 +161,6 @@ async def clear_module_field(
162
161
  store = SettingsStore(service)
163
162
  await store.clear_override(package, field)
164
163
 
165
- hydrated = await hydrate_settings(cls, store, package)
166
- services = getattr(request.app.state, package)
167
- services.settings = hydrated
168
-
169
- bus = request.app.state.sm.event_bus
170
- await bus.publish(SettingsReloaded(package=package, changed=(field,)))
164
+ reload_on_commit(request.app, request.app.state.sm.event_bus, store, package)
171
165
 
172
166
  return Response(status_code=status.HTTP_204_NO_CONTENT)
@@ -0,0 +1,70 @@
1
+ """Rehydrate module settings and notify local consumers only after durable writes."""
2
+
3
+ import logging
4
+
5
+ from fastapi import FastAPI
6
+ from simple_module_core.app_state import get_services
7
+ from simple_module_core.events import EventBus
8
+ from simple_module_core.invalidation import InvalidationBus
9
+ from simple_module_core.invalidation_refresh import InvalidationRefresh
10
+ from simple_module_core.module_settings import module_settings_registry
11
+ from simple_module_core.settings_hydrate import hydrate_settings
12
+ from simple_module_db.stored_settings import settings_table_exists
13
+
14
+ from settings.contracts.events import SettingsReloaded
15
+ from settings.store import SettingsStore
16
+
17
+ CHANNEL = "settings.modules"
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ async def reload_package(app: FastAPI, bus: EventBus, store: SettingsStore, package: str) -> None:
22
+ cls = module_settings_registry(app).get(package)
23
+ if cls is None:
24
+ return
25
+ services = getattr(app.state, package, None)
26
+ if services is None:
27
+ return
28
+ hydrated = await hydrate_settings(cls, store, package, lenient=True)
29
+ changed = tuple(
30
+ sorted(
31
+ name
32
+ for name in cls.model_fields
33
+ if getattr(services.settings, name) != getattr(hydrated, name)
34
+ )
35
+ )
36
+ services.settings = hydrated
37
+ if changed:
38
+ await bus.publish(SettingsReloaded(package=package, changed=changed))
39
+
40
+
41
+ def reload_on_commit(app: FastAPI, bus: EventBus, store: SettingsStore, package: str) -> None:
42
+ async def committed() -> None:
43
+ invalidation = getattr(get_services(app), "invalidation", None)
44
+ if invalidation is None:
45
+ await reload_package(app, bus, store, package)
46
+ else:
47
+ await invalidation.publish(CHANNEL, key=package)
48
+
49
+ store.on_commit(committed)
50
+
51
+
52
+ def subscribe(bus: InvalidationBus, app: FastAPI) -> InvalidationRefresh:
53
+ from settings.service import SettingService
54
+
55
+ async def reload(key: str | None) -> None:
56
+ async with app.state.sm.db.session_factory() as db:
57
+ if not await settings_table_exists(db): # fresh install, before migrations
58
+ return
59
+ store = SettingsStore(SettingService(db))
60
+ packages = [key] if key is not None else module_settings_registry(app).all_packages()
61
+ for package in packages:
62
+ # One package's failed read or invalid cross-field rule must not
63
+ # starve the packages after it on every later cycle.
64
+ try:
65
+ await reload_package(app, app.state.sm.event_bus, store, package)
66
+ except Exception:
67
+ logger.exception("Reloading %s settings failed; keeping current ones", package)
68
+ await db.rollback()
69
+
70
+ return InvalidationRefresh(bus, CHANNEL, reload)
@@ -29,6 +29,7 @@ from settings.constants import (
29
29
  )
30
30
 
31
31
  if TYPE_CHECKING:
32
+ from simple_module_core.invalidation import InvalidationBus
32
33
  from sqlalchemy.ext.asyncio import AsyncSession
33
34
 
34
35
 
@@ -79,6 +80,19 @@ class SettingsModule(ModuleBase):
79
80
  # Self-register so the UI lists our own settings alongside other modules.
80
81
  services.module_registry.register(MODULE_PACKAGE, SettingsSettings)
81
82
 
83
+ def register_invalidations(self, bus: InvalidationBus, app: FastAPI) -> None:
84
+ from settings.invalidation import subscribe
85
+
86
+ self._refresh = subscribe(bus, app)
87
+
88
+ async def on_startup(self, app: FastAPI) -> None:
89
+ if hasattr(self, "_refresh"):
90
+ self._refresh.start()
91
+
92
+ async def on_shutdown(self, app: FastAPI) -> None:
93
+ if hasattr(self, "_refresh"):
94
+ await self._refresh.stop()
95
+
82
96
  def register_exception_handlers(self, app: FastAPI) -> None:
83
97
  from settings.errors import install_exception_handlers
84
98
 
@@ -142,7 +142,11 @@ function ModulesEdit({ modules, testable = {} }: Props) {
142
142
  </div>
143
143
  </Card>
144
144
  ) : current ? (
145
- <ModuleForm module={current} checks={testable[current.package] ?? []} />
145
+ <ModuleForm
146
+ key={current.package}
147
+ module={current}
148
+ checks={testable[current.package] ?? []}
149
+ />
146
150
  ) : (
147
151
  <p className="text-muted-foreground">{t(keys.settings.modules.empty_title)}</p>
148
152
  )}
@@ -42,18 +42,23 @@ export function ModuleForm({ module: m, checks = [] }: Props) {
42
42
  return o;
43
43
  }, [m.fields]);
44
44
 
45
- const [values, setValues] = useState<Record<string, unknown>>(initial);
46
- const [baseline, setBaseline] = useState<Record<string, unknown>>(initial);
45
+ const [{ values, baseline }, setBuffer] = useState({ values: initial, baseline: initial });
47
46
  const [errors, setErrors] = useState<Record<string, string>>({});
48
47
  const [busy, setBusy] = useState(false);
49
48
  const [saved, setSaved] = useState(false);
50
49
  const [formErrors, setFormErrors] = useState<string[]>([]);
51
50
 
52
- // Reset the edit buffer whenever the underlying module changes (package
53
- // switch, or server-reloaded props after a save/reset).
51
+ // Refresh saved values without discarding drafts in other fields (#637).
54
52
  useEffect(() => {
55
- setValues(initial);
56
- setBaseline(initial);
53
+ setBuffer((prev) => ({
54
+ values: Object.fromEntries(
55
+ Object.entries(initial).map(([name, value]) => [
56
+ name,
57
+ notEqual(prev.values[name], prev.baseline[name]) ? prev.values[name] : value,
58
+ ]),
59
+ ),
60
+ baseline: initial,
61
+ }));
57
62
  setErrors({});
58
63
  setSaved(false);
59
64
  }, [initial]);
@@ -107,7 +112,7 @@ export function ModuleForm({ module: m, checks = [] }: Props) {
107
112
  } else {
108
113
  // Keeping the form mounted lets the confirmation remain visible instead
109
114
  // of being discarded by an immediate Inertia reload.
110
- setBaseline({ ...values });
115
+ setBuffer((prev) => ({ ...prev, baseline: { ...values } }));
111
116
  setSaved(true);
112
117
  }
113
118
  setBusy(false);
@@ -121,6 +126,10 @@ export function ModuleForm({ module: m, checks = [] }: Props) {
121
126
  params: { path: { package: m.package, field: name } },
122
127
  }),
123
128
  );
129
+ setBuffer((prev) => ({
130
+ ...prev,
131
+ values: { ...prev.values, [name]: prev.baseline[name] },
132
+ }));
124
133
  router.reload({ only: ['modules'] });
125
134
  }
126
135
 
@@ -187,7 +196,10 @@ export function ModuleForm({ module: m, checks = [] }: Props) {
187
196
  onReset={() => onReset(f.name)}
188
197
  onChange={(name, v) => {
189
198
  setSaved(false);
190
- setValues((prev) => ({ ...prev, [name]: v }));
199
+ setBuffer((prev) => ({
200
+ ...prev,
201
+ values: { ...prev.values, [name]: v },
202
+ }));
191
203
  }}
192
204
  />
193
205
  ))}
@@ -3,7 +3,7 @@ import { Button } from '@simple-module-py/ui/components/ui/button';
3
3
  import { useRelativeTime } from '@simple-module-py/ui/hooks/use-relative-time';
4
4
  import { apiFetch } from '@simple-module-py/ui/lib/api';
5
5
  import { CheckCircle2, PlugZap, XCircle } from 'lucide-react';
6
- import { useEffect, useState } from 'react';
6
+ import { useEffect, useRef, useState } from 'react';
7
7
  import { ROUTES } from '../routes';
8
8
  import { type LastTest, readLastTest, writeLastTest } from './last-test';
9
9
 
@@ -35,9 +35,19 @@ export function TestConnectionButton({ pkg, checks }: Props) {
35
35
  const [error, setError] = useState<string | null>(null);
36
36
  const [last, setLast] = useState<LastTest | null>(null);
37
37
 
38
+ const requestId = useRef(0);
39
+
38
40
  // Read on mount rather than during render: the result is per-tab session
39
41
  // state, and touching sessionStorage while rendering breaks SSR hydration.
40
- useEffect(() => setLast(readLastTest(pkg)), [pkg]);
42
+ useEffect(() => {
43
+ setLast(readLastTest(pkg));
44
+ setResults(null);
45
+ setError(null);
46
+ setBusy(false);
47
+ return () => {
48
+ requestId.current++;
49
+ };
50
+ }, [pkg]);
41
51
 
42
52
  // "users.mailer" → "mailer". Naming the check is the difference between
43
53
  // "Test connection" and knowing an SMTP dial is about to happen; with two
@@ -50,6 +60,7 @@ export function TestConnectionButton({ pkg, checks }: Props) {
50
60
  : t(keys.settings.modules.test_connection_generic);
51
61
 
52
62
  async function run() {
63
+ const id = ++requestId.current;
53
64
  setBusy(true);
54
65
  setError(null);
55
66
  setResults(null);
@@ -57,8 +68,9 @@ export function TestConnectionButton({ pkg, checks }: Props) {
57
68
  const result = await apiFetch<{ checks?: CheckResult[] }>(ROUTES.testConnection(pkg), {
58
69
  method: 'POST',
59
70
  });
60
- if (result.ok) {
61
- const checkResults = result.data?.checks ?? [];
71
+ if (id !== requestId.current) return;
72
+ const checkResults = result.data?.checks;
73
+ if (result.ok && Array.isArray(checkResults) && checkResults.length > 0) {
62
74
  setResults(checkResults);
63
75
  setLast(
64
76
  writeLastTest(
@@ -11,9 +11,13 @@ from pydantic_core import to_jsonable_python
11
11
  from pydantic_settings import BaseSettings
12
12
  from simple_module_core.events import EventBus
13
13
  from simple_module_core.module_settings import module_settings_registry
14
- from simple_module_core.settings_hydrate import env_supplied_fields, value_type_for_field
14
+ from simple_module_core.settings_hydrate import (
15
+ env_supplied_fields,
16
+ hydrate_settings,
17
+ value_type_for_field,
18
+ )
15
19
 
16
- from settings.contracts.events import SettingsReloaded
20
+ from settings.invalidation import reload_on_commit
17
21
  from settings.store import SettingsStore
18
22
 
19
23
 
@@ -87,7 +91,7 @@ async def apply_changes_and_reload(
87
91
  package: str,
88
92
  changes: dict[str, Any],
89
93
  ) -> BaseSettings:
90
- """Validate, persist, hot-swap, and publish ``SettingsReloaded``."""
94
+ """Validate and persist; reload and publish ``SettingsReloaded`` after commit."""
91
95
  registry = module_settings_registry(app)
92
96
  cls = registry.get(package)
93
97
  if cls is None:
@@ -97,8 +101,8 @@ async def apply_changes_and_reload(
97
101
  if unknown:
98
102
  raise KeyError(f"Unknown field(s) for {package!r}: {sorted(unknown)}")
99
103
 
100
- services = getattr(app.state, package)
101
- current = services.settings
104
+ # Include prior writes in this transaction without exposing them to other requests.
105
+ current = await hydrate_settings(cls, store, package, lenient=True)
102
106
  # Compare against the *dumped* current value, not the attribute: a field
103
107
  # typed as a list of models (branding's ``footer_links``) holds model
104
108
  # instances, while ``changes`` carries the plain dicts a DTO dumps to. The
@@ -118,6 +122,7 @@ async def apply_changes_and_reload(
118
122
  await store.clear_override(package, name)
119
123
 
120
124
  if not diff:
125
+ reload_on_commit(app, bus, store, package)
121
126
  return current
122
127
 
123
128
  # Only a field that would actually change is refused: a client that echoes
@@ -153,6 +158,5 @@ async def apply_changes_and_reload(
153
158
  except ValidationError as exc:
154
159
  raise _relocate(exc, field_name) from exc
155
160
 
156
- services.settings = validated
157
- await bus.publish(SettingsReloaded(package=package, changed=tuple(sorted(diff))))
161
+ reload_on_commit(app, bus, store, package)
158
162
  return validated
@@ -18,6 +18,7 @@ see ``_module_settings._field_view``.
18
18
 
19
19
  from __future__ import annotations
20
20
 
21
+ from simple_module_db.callbacks import OnCommitCallback
21
22
  from simple_module_db.stored_settings import ( # noqa: F401 (re-exported, #500)
22
23
  SYSTEM_SCOPE_ID,
23
24
  get_overrides_sync,
@@ -41,6 +42,9 @@ class SettingsStore:
41
42
  def __init__(self, service: SettingService) -> None:
42
43
  self._service = service
43
44
 
45
+ def on_commit(self, callback: OnCommitCallback) -> None:
46
+ self._service.db.on_commit(callback)
47
+
44
48
  async def get_overrides(self, package: str) -> dict[str, tuple[str, str]]:
45
49
  """Return ``{field_name: (raw_value, value_type)}`` for a package."""
46
50
  items = await self._service.list_by_scope_unmasked(SettingScope.SYSTEM, SYSTEM_SCOPE_ID)
@@ -14,6 +14,7 @@ from settings.settings import SettingsSettings
14
14
  from settings.store import SettingsStore
15
15
  from simple_module_core.events import EventBus
16
16
  from simple_module_core.module_settings import register_module_settings
17
+ from simple_module_db.transaction import finalize_session
17
18
 
18
19
 
19
20
  class _UsersCfg(BaseSettings):
@@ -56,7 +57,10 @@ async def test_apply_changes_updates_app_state_and_fires_event(app_and_bus, db_s
56
57
 
57
58
  assert new_settings.allow_signup is True
58
59
  assert new_settings.smtp_port == 587
59
- assert app.state.users.settings is new_settings
60
+ assert app.state.users.settings.allow_signup is False
61
+ assert received == []
62
+ await finalize_session(db_session)
63
+ assert app.state.users.settings == new_settings
60
64
  assert received == [SettingsReloaded(package="users", changed=("allow_signup", "smtp_port"))]
61
65
 
62
66
  persisted = await store.get_overrides("users")
@@ -96,3 +100,15 @@ async def test_apply_changes_unknown_package_raises(app_and_bus, db_session):
96
100
  package="unknown",
97
101
  changes={"x": 1},
98
102
  )
103
+
104
+
105
+ async def test_multiple_settings_edits_in_one_transaction_keep_both(app_and_bus, db_session):
106
+ app, bus = app_and_bus
107
+ store = SettingsStore(SettingService(db_session))
108
+ await apply_changes_and_reload(app, bus, store, package="users", changes={"allow_signup": True})
109
+ await apply_changes_and_reload(app, bus, store, package="users", changes={"smtp_port": 587})
110
+ assert app.state.users.settings.allow_signup is False
111
+ assert app.state.users.settings.smtp_port == 25
112
+ await finalize_session(db_session)
113
+ assert app.state.users.settings.allow_signup is True
114
+ assert app.state.users.settings.smtp_port == 587
@@ -0,0 +1,99 @@
1
+ """Module settings and reload events only reflect committed state."""
2
+
3
+ import pytest
4
+ from settings.contracts.events import SettingsReloaded
5
+ from settings.reload import apply_changes_and_reload
6
+ from settings.service import SettingService
7
+ from settings.store import SettingsStore
8
+ from simple_module_core.invalidation import Invalidation
9
+ from simple_module_db.transaction import finalize_session, rollback_session
10
+
11
+
12
+ @pytest.mark.parametrize("commit", [False, True])
13
+ async def test_settings_and_events_wait_for_commit(app, commit):
14
+ received = []
15
+ bus = app.state.sm.event_bus
16
+
17
+ async def record(event):
18
+ received.append(event)
19
+
20
+ bus.subscribe(SettingsReloaded, record)
21
+ original = app.state.users.settings
22
+ wanted = not original.allow_signup
23
+ async with app.state.sm.db.session_factory() as db:
24
+ await apply_changes_and_reload(
25
+ app,
26
+ bus,
27
+ SettingsStore(SettingService(db)),
28
+ package="users",
29
+ changes={"allow_signup": wanted},
30
+ )
31
+ assert app.state.users.settings is original
32
+ assert received == []
33
+ if commit:
34
+ await finalize_session(db)
35
+ else:
36
+ await rollback_session(db)
37
+ assert app.state.users.settings.allow_signup is (wanted if commit else original.allow_signup)
38
+ assert len(received) == int(commit)
39
+ if commit:
40
+ assert received[0].changed == ("allow_signup",)
41
+
42
+
43
+ async def test_remote_settings_changes_and_clears_reload_and_emit_event(app):
44
+ received = []
45
+
46
+ async def record(event):
47
+ received.append(event)
48
+
49
+ app.state.sm.event_bus.subscribe(SettingsReloaded, record)
50
+ original = app.state.users.settings.allow_signup
51
+ factory = app.state.sm.db.session_factory
52
+ async with factory() as db:
53
+ await SettingsStore(SettingService(db)).set_override(
54
+ "users", "allow_signup", str(not original), "bool"
55
+ )
56
+ await db.commit()
57
+ message = Invalidation(channel="settings.modules", key="users", origin="other")
58
+ await app.state.sm.invalidation.deliver(message.to_wire())
59
+ assert app.state.users.settings.allow_signup is not original
60
+ assert received[-1].changed == ("allow_signup",)
61
+ async with factory() as db:
62
+ await SettingsStore(SettingService(db)).clear_override("users", "allow_signup")
63
+ await db.commit()
64
+ await app.state.sm.invalidation.deliver(message.to_wire())
65
+ assert app.state.users.settings.allow_signup is original
66
+ assert len(received) == 2
67
+
68
+
69
+ @pytest.mark.parametrize("commit", [False, True])
70
+ async def test_clearing_module_setting_waits_for_commit(app, commit):
71
+ from settings.endpoints.module_api import clear_module_field
72
+ from starlette.requests import Request
73
+
74
+ original = app.state.users.settings.allow_signup
75
+ factory = app.state.sm.db.session_factory
76
+ async with factory() as db:
77
+ await SettingsStore(SettingService(db)).set_override(
78
+ "users", "allow_signup", str(not original), "bool"
79
+ )
80
+ await db.commit()
81
+ await app.state.sm.invalidation.publish("settings.modules", key="users")
82
+ received = []
83
+
84
+ async def record(event):
85
+ received.append(event)
86
+
87
+ app.state.sm.event_bus.subscribe(SettingsReloaded, record)
88
+ async with factory() as db:
89
+ await clear_module_field(
90
+ "users", "allow_signup", Request({"type": "http", "app": app}), SettingService(db)
91
+ )
92
+ assert app.state.users.settings.allow_signup is not original
93
+ assert received == []
94
+ if commit:
95
+ await finalize_session(db)
96
+ else:
97
+ await rollback_session(db)
98
+ assert app.state.users.settings.allow_signup is (original if commit else not original)
99
+ assert len(received) == int(commit)
@@ -25,6 +25,13 @@ class _SecretCfg(BaseSettings):
25
25
  class _Store:
26
26
  def __init__(self) -> None:
27
27
  self.rows: dict[str, tuple[str, str]] = {}
28
+ self.callbacks = []
29
+
30
+ def on_commit(self, callback) -> None:
31
+ self.callbacks.append(callback)
32
+
33
+ async def get_overrides(self, package):
34
+ return dict(self.rows)
28
35
 
29
36
  async def set_override(self, package, field, raw, value_type) -> None:
30
37
  self.rows[field] = (raw, value_type)
@@ -0,0 +1,88 @@
1
+ import '@testing-library/jest-dom/vitest';
2
+ import { configureI18n } from '@simple-module-py/i18n';
3
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
4
+ import { afterEach, expect, test, vi } from 'vitest';
5
+ import type { FieldMeta } from '../settings/pages/components/FieldInput';
6
+ import { ModuleForm, type ModuleView } from '../settings/pages/components/ModuleForm';
7
+ import ModulesEdit from '../settings/pages/ModulesEdit';
8
+
9
+ const mocks = vi.hoisted(() => ({ reload: vi.fn() }));
10
+ vi.mock('@inertiajs/react', () => ({ router: { reload: mocks.reload }, Head: () => null }));
11
+ configureI18n({
12
+ locale: 'en',
13
+ messages: {
14
+ 'settings.modules_form.save': 'Save',
15
+ 'settings.modules_form.reset_to_default': 'Revert',
16
+ 'settings.modules.unsaved_count_one': '{count} unsaved',
17
+ },
18
+ });
19
+
20
+ function field(name: string, value: string, db_override = false): FieldMeta {
21
+ return {
22
+ name,
23
+ value,
24
+ db_override,
25
+ type: 'string',
26
+ default: '',
27
+ description: '',
28
+ is_secret: false,
29
+ requires_restart: false,
30
+ group: null,
31
+ env_var: 'SM_TEST_FIELD',
32
+ env_set: false,
33
+ env_readable: false,
34
+ choices: null,
35
+ };
36
+ }
37
+ function view(fields: FieldMeta[]): ModuleView {
38
+ return {
39
+ module_name: 'Users',
40
+ package: 'users',
41
+ env_prefix: 'SM_USERS_',
42
+ class_name: 'UsersSettings',
43
+ fields,
44
+ };
45
+ }
46
+
47
+ afterEach(() => {
48
+ vi.unstubAllGlobals();
49
+ vi.clearAllMocks();
50
+ });
51
+
52
+ test('reverting one field preserves other drafts and their saved baseline after props reload', async () => {
53
+ const fetchMock = vi.fn(async () => new Response('{}', { status: 200 }));
54
+ vi.stubGlobal('fetch', fetchMock);
55
+ const { rerender } = render(
56
+ <ModuleForm
57
+ module={view([field('smtp_host', 'override', true), field('sender', 'original')])}
58
+ />,
59
+ );
60
+ fireEvent.change(screen.getByLabelText('sender'), { target: { value: 'draft' } });
61
+ fireEvent.change(screen.getByLabelText('smtp_host'), { target: { value: 'discard me' } });
62
+ fireEvent.click(screen.getByRole('button', { name: 'Revert' }));
63
+ await waitFor(() => expect(mocks.reload).toHaveBeenCalled());
64
+ rerender(
65
+ <ModuleForm module={view([field('smtp_host', 'default'), field('sender', 'original')])} />,
66
+ );
67
+ expect(screen.getByLabelText('smtp_host')).toHaveValue('default');
68
+ expect(screen.getByLabelText('sender')).toHaveValue('draft');
69
+ expect(screen.getByText('1 unsaved')).toBeVisible();
70
+ fireEvent.click(screen.getByRole('button', { name: 'Save' }));
71
+ await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
72
+ const [request] = fetchMock.mock.calls[1] as unknown as [Request];
73
+ expect(await request.json()).toEqual({ sender: 'draft' });
74
+ });
75
+
76
+ test('switching modules starts a fresh edit buffer even when field names match', () => {
77
+ const users = view([field('sender', 'users original')]);
78
+ const mail = {
79
+ ...view([field('sender', 'mail original')]),
80
+ module_name: 'Mail',
81
+ package: 'mail',
82
+ };
83
+ render(<ModulesEdit modules={[users, mail]} />);
84
+ fireEvent.change(screen.getByLabelText('sender'), { target: { value: 'users draft' } });
85
+ fireEvent.click(screen.getByRole('button', { name: /mail/ }));
86
+ expect(screen.getByLabelText('sender')).toHaveValue('mail original');
87
+ expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled();
88
+ });
@@ -0,0 +1,111 @@
1
+ import '@testing-library/jest-dom/vitest';
2
+ import { configureI18n, keys } from '@simple-module-py/i18n';
3
+ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
4
+ import { afterEach, describe, expect, it, vi } from 'vitest';
5
+
6
+ configureI18n({ locale: 'en', messages: {} });
7
+
8
+ import { TestConnectionButton } from '../settings/pages/components/TestConnectionButton';
9
+
10
+ function stub(status: number, body: unknown) {
11
+ const fetchMock = vi.fn(async () => new Response(JSON.stringify(body), { status }));
12
+ vi.stubGlobal('fetch', fetchMock);
13
+ return fetchMock;
14
+ }
15
+
16
+ const click = () =>
17
+ fireEvent.click(
18
+ screen.getByRole('button', { name: keys.settings.modules.test_connection_generic }),
19
+ );
20
+
21
+ describe('TestConnectionButton', () => {
22
+ afterEach(() => {
23
+ vi.unstubAllGlobals();
24
+ window.sessionStorage.clear();
25
+ });
26
+
27
+ it('clears the displayed result when the package changes', async () => {
28
+ stub(200, { checks: [{ name: 'a.b', status: 'healthy', detail: 'users connected' }] });
29
+ const { rerender } = render(<TestConnectionButton pkg="users" checks={['a.b', 'c.d']} />);
30
+ click();
31
+ expect(await screen.findByText('users connected')).toBeVisible();
32
+ rerender(<TestConnectionButton pkg="mail" checks={['a.b', 'c.d']} />);
33
+ expect(screen.queryByText('users connected')).not.toBeInTheDocument();
34
+ });
35
+
36
+ it('ignores a pending result after switching packages, including switching back', async () => {
37
+ let finish!: (response: Response) => void;
38
+ vi.stubGlobal(
39
+ 'fetch',
40
+ vi.fn(
41
+ () =>
42
+ new Promise<Response>((resolve) => {
43
+ finish = resolve;
44
+ }),
45
+ ),
46
+ );
47
+ const { rerender } = render(<TestConnectionButton pkg="users" checks={['a.b', 'c.d']} />);
48
+ click();
49
+ rerender(<TestConnectionButton pkg="mail" checks={['a.b', 'c.d']} />);
50
+ expect(screen.getByRole('button')).toBeEnabled();
51
+ rerender(<TestConnectionButton pkg="users" checks={['a.b', 'c.d']} />);
52
+ await act(async () =>
53
+ finish(
54
+ new Response(
55
+ JSON.stringify({
56
+ checks: [{ name: 'a.b', status: 'healthy', detail: 'stale result' }],
57
+ }),
58
+ { status: 200 },
59
+ ),
60
+ ),
61
+ );
62
+ expect(screen.queryByText('stale result')).not.toBeInTheDocument();
63
+ expect(window.sessionStorage.getItem('sm.settings.last-test.users')).toBeNull();
64
+ });
65
+
66
+ it.each([null, {}, { checks: [] }, { checks: 'invalid' }])(
67
+ 'does not record success for an invalid check response: %j',
68
+ async (body) => {
69
+ stub(200, body);
70
+ render(<TestConnectionButton pkg="users" checks={['a.b', 'c.d']} />);
71
+ click();
72
+ expect(await screen.findByText(keys.settings.modules.test_failed)).toBeVisible();
73
+ expect(
74
+ JSON.parse(window.sessionStorage.getItem('sm.settings.last-test.users') ?? '{}').ok,
75
+ ).toBe(false);
76
+ },
77
+ );
78
+
79
+ it('does not record success when the response body cannot be read', async () => {
80
+ const response = new Response('{}', { status: 200 });
81
+ vi.spyOn(response, 'text').mockRejectedValue(new TypeError('stream interrupted'));
82
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response));
83
+ render(<TestConnectionButton pkg="users" checks={['a.b', 'c.d']} />);
84
+ click();
85
+ expect(await screen.findByText(keys.settings.modules.test_failed)).toBeVisible();
86
+ expect(
87
+ JSON.parse(window.sessionStorage.getItem('sm.settings.last-test.users') ?? '{}').ok,
88
+ ).toBe(false);
89
+ });
90
+
91
+ it('POSTs to the view route and shows each check detail', async () => {
92
+ const fetchMock = stub(200, {
93
+ checks: [{ name: 'users.mailer', status: 'unhealthy', detail: 'connection refused' }],
94
+ });
95
+ render(<TestConnectionButton pkg="users" checks={['a.b', 'c.d']} />);
96
+ click();
97
+ expect(await screen.findByText('connection refused')).toBeInTheDocument();
98
+ const [req] = fetchMock.mock.calls[0] as unknown as [Request];
99
+ expect(req.method).toBe('POST');
100
+ expect(new URL(req.url).pathname).toBe('/admin/settings/test-connection/users');
101
+ });
102
+
103
+ it('shows a translated line when the call itself fails', async () => {
104
+ stub(500, null);
105
+ render(<TestConnectionButton pkg="users" checks={['a.b', 'c.d']} />);
106
+ click();
107
+ await waitFor(() =>
108
+ expect(screen.getByText(keys.settings.modules.test_failed)).toBeInTheDocument(),
109
+ );
110
+ });
111
+ });
@@ -1,47 +0,0 @@
1
- import '@testing-library/jest-dom/vitest';
2
- import { configureI18n, keys } from '@simple-module-py/i18n';
3
- import { fireEvent, render, screen, waitFor } from '@testing-library/react';
4
- import { afterEach, describe, expect, it, vi } from 'vitest';
5
-
6
- configureI18n({ locale: 'en', messages: {} });
7
-
8
- import { TestConnectionButton } from '../settings/pages/components/TestConnectionButton';
9
-
10
- function stub(status: number, body: unknown) {
11
- const fetchMock = vi.fn(async () => new Response(JSON.stringify(body), { status }));
12
- vi.stubGlobal('fetch', fetchMock);
13
- return fetchMock;
14
- }
15
-
16
- const click = () =>
17
- fireEvent.click(
18
- screen.getByRole('button', { name: keys.settings.modules.test_connection_generic }),
19
- );
20
-
21
- describe('TestConnectionButton', () => {
22
- afterEach(() => {
23
- vi.unstubAllGlobals();
24
- window.sessionStorage.clear();
25
- });
26
-
27
- it('POSTs to the view route and shows each check detail', async () => {
28
- const fetchMock = stub(200, {
29
- checks: [{ name: 'users.mailer', status: 'unhealthy', detail: 'connection refused' }],
30
- });
31
- render(<TestConnectionButton pkg="users" checks={['a.b', 'c.d']} />);
32
- click();
33
- expect(await screen.findByText('connection refused')).toBeInTheDocument();
34
- const [req] = fetchMock.mock.calls[0] as unknown as [Request];
35
- expect(req.method).toBe('POST');
36
- expect(new URL(req.url).pathname).toBe('/admin/settings/test-connection/users');
37
- });
38
-
39
- it('shows a translated line when the call itself fails', async () => {
40
- stub(500, null);
41
- render(<TestConnectionButton pkg="users" checks={['a.b', 'c.d']} />);
42
- click();
43
- await waitFor(() =>
44
- expect(screen.getByText(keys.settings.modules.test_failed)).toBeInTheDocument(),
45
- );
46
- });
47
- });