simple-module-feature-flags 0.0.38__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 (40) hide show
  1. {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/PKG-INFO +4 -4
  2. {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/feature_flags/deps.py +2 -1
  3. {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/feature_flags/endpoints/api.py +4 -0
  4. simple_module_feature_flags-0.0.40/feature_flags/invalidation.py +17 -0
  5. {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/feature_flags/module.py +21 -0
  6. {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/feature_flags/service.py +33 -12
  7. simple_module_feature_flags-0.0.40/feature_flags/tenant_cleanup.py +18 -0
  8. {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/package.json +1 -1
  9. {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/pyproject.toml +4 -4
  10. simple_module_feature_flags-0.0.40/tests/test_feature_flags_commit_invalidation.py +78 -0
  11. {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/tests/test_feature_flags_service.py +10 -0
  12. simple_module_feature_flags-0.0.40/tests/test_feature_flags_tenant_cleanup.py +34 -0
  13. {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/.gitignore +0 -0
  14. {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/LICENSE +0 -0
  15. {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/README.md +0 -0
  16. {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/feature_flags/__init__.py +0 -0
  17. {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/feature_flags/constants.py +0 -0
  18. {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/feature_flags/contracts/__init__.py +0 -0
  19. {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/feature_flags/contracts/schemas.py +0 -0
  20. {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/feature_flags/endpoints/__init__.py +0 -0
  21. {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/feature_flags/endpoints/views.py +0 -0
  22. {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/feature_flags/locales/de.json +0 -0
  23. {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/feature_flags/locales/en.json +0 -0
  24. {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/feature_flags/models.py +0 -0
  25. {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/feature_flags/pages/Browse.tsx +0 -0
  26. {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/feature_flags/pages/components/FlagTable.tsx +0 -0
  27. {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/feature_flags/pages/components/ScopeCard.tsx +0 -0
  28. {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/feature_flags/pages/components/TenantPicker.tsx +0 -0
  29. {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/feature_flags/pages/components/ToggleConfirmDialog.tsx +0 -0
  30. {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/feature_flags/pages/types.ts +0 -0
  31. {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/feature_flags/py.typed +0 -0
  32. {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/tests/conftest.py +0 -0
  33. {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/tests/test_feature_flags_api.py +0 -0
  34. {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/tests/test_feature_flags_audit_links.py +0 -0
  35. {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/tests/test_feature_flags_menu_section.py +0 -0
  36. {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/tests/test_feature_flags_tenancy.py +0 -0
  37. {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/tests/test_flag_description_i18n.py +0 -0
  38. {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/tests/test_store_outage_fallback.py +0 -0
  39. {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/tests-js/feature_flags_Browse.test.tsx +0 -0
  40. {simple_module_feature_flags-0.0.38 → simple_module_feature_flags-0.0.40}/tsconfig.json +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: simple_module_feature_flags
3
- Version: 0.0.38
3
+ Version: 0.0.40
4
4
  Summary: Simple feature-flag module with per-tenant overrides and a consumer API for simple_module
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.38
25
- Requires-Dist: simple-module-db==0.0.38
26
- Requires-Dist: simple-module-hosting==0.0.38
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
  Description-Content-Type: text/markdown
28
28
 
29
29
  # simple_module_feature_flags
@@ -14,9 +14,10 @@ from feature_flags.service import FeatureFlagService
14
14
 
15
15
 
16
16
  async def get_feature_flag_service(
17
+ request: Request,
17
18
  db: AsyncSession = Depends(get_db),
18
19
  ) -> FeatureFlagService:
19
- return FeatureFlagService(db)
20
+ return FeatureFlagService(db, app=request.app)
20
21
 
21
22
 
22
23
  async def get_feature_flag_registry(request: Request) -> FeatureFlagRegistry:
@@ -70,6 +70,8 @@ async def set_override(
70
70
  await service.set_override(name, body.enabled, registry=registry)
71
71
  view = service.build_view(registry, name)
72
72
  assert view is not None # _ensure_registered already proved it exists
73
+ view.enabled = body.enabled
74
+ view.overridden = True
73
75
  return view
74
76
 
75
77
 
@@ -125,6 +127,8 @@ async def set_tenant_override(
125
127
  )
126
128
  view = service.build_view(registry, name, tenant_id=tenant_id)
127
129
  assert view is not None # _ensure_registered already proved it exists
130
+ view.enabled = body.enabled
131
+ view.overridden = True
128
132
  return view
129
133
 
130
134
 
@@ -0,0 +1,17 @@
1
+ """Re-read flag overrides after commit or a remote invalidation."""
2
+
3
+ from fastapi import FastAPI
4
+ from simple_module_core.invalidation import InvalidationBus
5
+ from simple_module_core.invalidation_refresh import InvalidationRefresh
6
+
7
+ from feature_flags.service import FeatureFlagService
8
+
9
+ CHANNEL = "feature_flags.overrides"
10
+
11
+
12
+ def subscribe(bus: InvalidationBus, app: FastAPI) -> InvalidationRefresh:
13
+ async def reload(_key: str | None) -> None:
14
+ async with app.state.sm.db.session_factory() as db:
15
+ await FeatureFlagService(db).hydrate_registry(app.state.sm.feature_flags)
16
+
17
+ return InvalidationRefresh(bus, CHANNEL, reload)
@@ -29,6 +29,8 @@ from feature_flags.constants import (
29
29
  )
30
30
 
31
31
  if TYPE_CHECKING:
32
+ from simple_module_core.events import EventBus
33
+ from simple_module_core.invalidation import InvalidationBus
32
34
  from sqlalchemy.ext.asyncio import AsyncSession
33
35
 
34
36
  _logger = logging.getLogger(__name__)
@@ -67,6 +69,18 @@ class FeatureFlagsModule(ModuleBase):
67
69
  i18n_audience="admin",
68
70
  )
69
71
 
72
+ def register_event_handlers(self, bus: EventBus, app: FastAPI | None = None) -> None:
73
+ from simple_module_core.tenant_events import TenantDeleted
74
+
75
+ from feature_flags.tenant_cleanup import purge_tenant_data
76
+
77
+ bus.subscribe_durable(TenantDeleted, purge_tenant_data)
78
+
79
+ def register_invalidations(self, bus: InvalidationBus, app: FastAPI) -> None:
80
+ from feature_flags.invalidation import subscribe
81
+
82
+ self._refresh = subscribe(bus, app)
83
+
70
84
  def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None:
71
85
  from feature_flags.endpoints.api import router as api
72
86
  from feature_flags.endpoints.views import router as views
@@ -149,3 +163,10 @@ class FeatureFlagsModule(ModuleBase):
149
163
  await service.hydrate_registry(sm.feature_flags)
150
164
  except Exception:
151
165
  _logger.exception("feature_flags.hydrate_failed — continuing with registry defaults")
166
+
167
+ if hasattr(self, "_refresh"):
168
+ self._refresh.start()
169
+
170
+ async def on_shutdown(self, app: FastAPI) -> None:
171
+ if hasattr(self, "_refresh"):
172
+ await self._refresh.stop()
@@ -9,9 +9,11 @@ from __future__ import annotations
9
9
 
10
10
  from collections.abc import Callable
11
11
 
12
+ from fastapi import FastAPI
12
13
  from simple_module_core.feature_flags import FeatureFlagDefinition, FeatureFlagRegistry
13
14
  from sqlalchemy import select
14
15
  from sqlalchemy.ext.asyncio import AsyncSession
16
+ from sqlmodel import col
15
17
 
16
18
  from feature_flags.constants import SCOPE_SYSTEM, SCOPE_TENANT, SYSTEM_SCOPE_ID
17
19
  from feature_flags.contracts.schemas import FeatureFlagOverrideOut, FeatureFlagView
@@ -59,8 +61,9 @@ class FeatureFlagService:
59
61
  extra DB hit per request.
60
62
  """
61
63
 
62
- def __init__(self, db: AsyncSession) -> None:
64
+ def __init__(self, db: AsyncSession, app: FastAPI | None = None) -> None:
63
65
  self.db = db
66
+ self.app = app
64
67
 
65
68
  async def list_overrides(self) -> list[FeatureFlagOverrideOut]:
66
69
  """Every persisted override across all scopes, ordered for stable display."""
@@ -151,9 +154,9 @@ class FeatureFlagService:
151
154
  async def _find(self, scope: str, scope_id: str, name: str) -> FeatureFlagOverride | None:
152
155
  result = await self.db.execute(
153
156
  select(FeatureFlagOverride).where(
154
- FeatureFlagOverride.scope == scope, # ty: ignore[invalid-argument-type]
155
- FeatureFlagOverride.scope_id == scope_id, # ty: ignore[invalid-argument-type]
156
- FeatureFlagOverride.name == name, # ty: ignore[invalid-argument-type]
157
+ col(FeatureFlagOverride.scope) == scope,
158
+ col(FeatureFlagOverride.scope_id) == scope_id,
159
+ col(FeatureFlagOverride.name) == name,
157
160
  )
158
161
  )
159
162
  return result.scalar_one_or_none()
@@ -178,7 +181,7 @@ class FeatureFlagService:
178
181
  existing.enabled = enabled
179
182
  await self.db.flush()
180
183
  if registry is not None:
181
- registry.set_override(name, enabled, tenant_id=_registry_tenant_id(scope, scope_id))
184
+ self._on_commit(registry, name, enabled, scope, scope_id)
182
185
  return FeatureFlagOverrideOut.model_validate(existing)
183
186
 
184
187
  async def clear_override(
@@ -194,16 +197,34 @@ class FeatureFlagService:
194
197
  return False
195
198
  await self.db.delete(existing)
196
199
  if registry is not None:
197
- registry.clear_override(name, tenant_id=_registry_tenant_id(scope, scope_id))
200
+ self._on_commit(registry, name, None, scope, scope_id)
198
201
  return True
199
202
 
200
203
  async def hydrate_registry(self, registry: FeatureFlagRegistry) -> int:
201
204
  """Load every persisted override (system + tenant) into the registry at boot."""
202
205
  overrides = await self.list_overrides()
203
- for ovr in overrides:
204
- registry.set_override(
205
- ovr.name,
206
- ovr.enabled,
207
- tenant_id=_registry_tenant_id(ovr.scope, ovr.scope_id),
208
- )
206
+ registry.replace_overrides(
207
+ {o.name: o.enabled for o in overrides if o.scope == SCOPE_SYSTEM},
208
+ {(o.name, o.scope_id): o.enabled for o in overrides if o.scope == SCOPE_TENANT},
209
+ )
209
210
  return len(overrides)
211
+
212
+ def _on_commit(
213
+ self,
214
+ registry: FeatureFlagRegistry,
215
+ name: str,
216
+ enabled: bool | None,
217
+ scope: str,
218
+ scope_id: str,
219
+ ) -> None:
220
+ async def committed() -> None:
221
+ if self.app is not None:
222
+ from feature_flags.invalidation import CHANNEL
223
+
224
+ await self.app.state.sm.invalidation.publish(CHANNEL, key=name)
225
+ elif enabled is None:
226
+ registry.clear_override(name, tenant_id=_registry_tenant_id(scope, scope_id))
227
+ else:
228
+ registry.set_override(name, enabled, tenant_id=_registry_tenant_id(scope, scope_id))
229
+
230
+ self.db.on_commit(committed)
@@ -0,0 +1,18 @@
1
+ """Durable cleanup of tenant-scoped feature flag overrides."""
2
+
3
+ from simple_module_core.durable_events import DurableContext
4
+ from simple_module_core.tenant_events import TenantDeleted
5
+ from sqlmodel import col, delete
6
+
7
+ from feature_flags.constants import SCOPE_TENANT
8
+ from feature_flags.models import FeatureFlagOverride
9
+
10
+
11
+ async def purge_tenant_data(event: TenantDeleted, ctx: DurableContext) -> None:
12
+ """Keep system/other tenants' overrides; acknowledge with the worker transaction."""
13
+ await ctx.db.execute(
14
+ delete(FeatureFlagOverride).where(
15
+ col(FeatureFlagOverride.scope) == SCOPE_TENANT,
16
+ col(FeatureFlagOverride.scope_id) == event.tenant_id,
17
+ )
18
+ )
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simple-module-py/feature_flags",
3
- "version": "0.0.38",
3
+ "version": "0.0.40",
4
4
  "private": true,
5
5
  "description": "Frontend assets for the FeatureFlags module",
6
6
  "peerDependencies": {
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_feature_flags"
3
- version = "0.0.38"
3
+ version = "0.0.40"
4
4
  description = "Simple feature-flag module with per-tenant overrides and a consumer API for simple_module"
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.38",
25
- "simple_module_db==0.0.38",
26
- "simple_module_hosting==0.0.38",
24
+ "simple_module_core==0.0.40",
25
+ "simple_module_db==0.0.40",
26
+ "simple_module_hosting==0.0.40",
27
27
  ]
28
28
 
29
29
  [project.entry-points.simple_module]
@@ -0,0 +1,78 @@
1
+ """Flag changes must survive commit before reaching any worker's registry."""
2
+
3
+ import pytest
4
+ from feature_flags.models import FeatureFlagOverride
5
+ from feature_flags.service import FeatureFlagService
6
+ from simple_module_core.feature_flags import FeatureFlagDefinition, FeatureFlagRegistry
7
+ from simple_module_core.invalidation import Invalidation
8
+ from simple_module_db.transaction import finalize_session, rollback_session
9
+
10
+
11
+ @pytest.mark.parametrize("commit", [False, True])
12
+ @pytest.mark.parametrize("scope,scope_id", [("system", ""), ("tenant", "acme")])
13
+ @pytest.mark.parametrize("clear", [False, True])
14
+ async def test_flag_mutation_waits_for_commit(db_session, commit, scope, scope_id, clear):
15
+ registry = FeatureFlagRegistry()
16
+ registry.add(FeatureFlagDefinition("switch"))
17
+ tenant_id = scope_id or None
18
+ if clear:
19
+ db_session.add(
20
+ FeatureFlagOverride(scope=scope, scope_id=scope_id, name="switch", enabled=True)
21
+ )
22
+ await db_session.commit()
23
+ registry.set_override("switch", True, tenant_id=tenant_id)
24
+ service = FeatureFlagService(db_session)
25
+ if clear:
26
+ await service.clear_override("switch", registry, scope, scope_id)
27
+ else:
28
+ await service.set_override("switch", True, registry, scope, scope_id)
29
+ assert registry.is_enabled("switch", tenant_id) is clear
30
+ if commit:
31
+ await finalize_session(db_session)
32
+ else:
33
+ await rollback_session(db_session)
34
+ assert registry.is_enabled("switch", tenant_id) is (clear != commit)
35
+
36
+
37
+ @pytest.mark.parametrize("scope,scope_id", [("system", ""), ("tenant", "acme")])
38
+ async def test_remote_flag_changes_and_deletions_reload(app, scope, scope_id):
39
+ registry = app.state.sm.feature_flags
40
+ registry.add(FeatureFlagDefinition("switch"))
41
+ factory = app.state.sm.db.session_factory
42
+ async with factory() as db:
43
+ row = FeatureFlagOverride(scope=scope, scope_id=scope_id, name="switch", enabled=True)
44
+ db.add(row)
45
+ await db.commit()
46
+ row_id = row.id
47
+ bus = app.state.sm.invalidation
48
+ message = Invalidation(channel="feature_flags.overrides", origin="other")
49
+ await bus.deliver(message.to_wire())
50
+ assert registry.is_enabled("switch", scope_id or None) is True
51
+ async with factory() as db:
52
+ await db.delete(await db.get(FeatureFlagOverride, row_id))
53
+ await db.commit()
54
+ await bus.deliver(message.to_wire())
55
+ assert registry.is_enabled("switch", scope_id or None) is False
56
+
57
+
58
+ @pytest.mark.parametrize("commit", [False, True])
59
+ @pytest.mark.parametrize("scope,scope_id", [("system", ""), ("tenant", "acme")])
60
+ async def test_flag_request_publishes_only_after_commit(app, commit, scope, scope_id):
61
+ from feature_flags.deps import get_feature_flag_service
62
+ from starlette.requests import Request
63
+
64
+ registry = app.state.sm.feature_flags
65
+ registry.add(FeatureFlagDefinition("switch"))
66
+ messages = []
67
+ app.state.sm.invalidation.subscribe("feature_flags.overrides", messages.append)
68
+ async with app.state.sm.db.session_factory() as db:
69
+ service = await get_feature_flag_service(Request({"type": "http", "app": app}), db)
70
+ await service.set_override("switch", True, registry, scope, scope_id)
71
+ assert registry.is_enabled("switch", scope_id or None) is False
72
+ assert messages == []
73
+ if commit:
74
+ await finalize_session(db)
75
+ else:
76
+ await rollback_session(db)
77
+ assert registry.is_enabled("switch", scope_id or None) is commit
78
+ assert len(messages) == int(commit)
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  from feature_flags.constants import SCOPE_TENANT
6
6
  from feature_flags.service import FeatureFlagService
7
7
  from simple_module_core.feature_flags import FeatureFlagDefinition, FeatureFlagRegistry
8
+ from simple_module_db.transaction import finalize_session
8
9
  from sqlalchemy.ext.asyncio import AsyncSession
9
10
 
10
11
 
@@ -25,6 +26,7 @@ class TestFeatureFlagServiceSystem:
25
26
  assert result.name == "beta_ui"
26
27
  assert result.scope == "system"
27
28
  assert result.enabled is True
29
+ await finalize_session(db_session)
28
30
  assert reg.is_enabled("beta_ui") is True
29
31
 
30
32
  async def test_set_override_twice_updates_in_place(self, db_session: AsyncSession):
@@ -37,6 +39,7 @@ class TestFeatureFlagServiceSystem:
37
39
  rows = await svc.list_overrides()
38
40
  assert len(rows) == 1
39
41
  assert rows[0].enabled is False
42
+ await finalize_session(db_session)
40
43
  assert reg.is_enabled("beta_ui") is False
41
44
 
42
45
  async def test_clear_override_removes_row_and_reverts_registry(self, db_session: AsyncSession):
@@ -48,6 +51,7 @@ class TestFeatureFlagServiceSystem:
48
51
 
49
52
  assert cleared is True
50
53
  assert await svc.list_overrides() == []
54
+ await finalize_session(db_session)
51
55
  assert reg.is_enabled("beta_ui") is False # back to default
52
56
 
53
57
  async def test_clear_override_unknown_flag_is_noop(self, db_session: AsyncSession):
@@ -62,6 +66,7 @@ class TestFeatureFlagServiceSystem:
62
66
  )
63
67
  await svc.set_override("a.flag", True, registry=reg)
64
68
 
69
+ await finalize_session(db_session)
65
70
  views = await svc.list_flags(reg)
66
71
 
67
72
  by_name = {v.name: v for v in views}
@@ -82,6 +87,7 @@ class TestFeatureFlagServiceSystem:
82
87
  await svc.set_override("legacy", True, registry=reg_old)
83
88
 
84
89
  reg_new = _registry_with(("current", "", False))
90
+ await finalize_session(db_session)
85
91
  views = await svc.list_flags(reg_new)
86
92
 
87
93
  assert [v.name for v in views] == ["current"]
@@ -98,6 +104,7 @@ class TestFeatureFlagServiceTenant:
98
104
  rows = await svc.list_overrides()
99
105
  assert len(rows) == 2
100
106
  # tenant row beats system for that tenant
107
+ await finalize_session(db_session)
101
108
  assert reg.is_enabled("beta_ui", tenant_id="acme") is False
102
109
  # other tenants fall back to system
103
110
  assert reg.is_enabled("beta_ui", tenant_id="other") is True
@@ -115,6 +122,7 @@ class TestFeatureFlagServiceTenant:
115
122
  )
116
123
 
117
124
  assert cleared is True
125
+ await finalize_session(db_session)
118
126
  assert reg.is_enabled("beta_ui", tenant_id="acme") is False
119
127
  assert reg.is_enabled("beta_ui", tenant_id="globex") is True
120
128
 
@@ -124,6 +132,7 @@ class TestFeatureFlagServiceTenant:
124
132
  await svc.set_override("beta_ui", True, registry=reg) # system on
125
133
  await svc.set_override("beta_ui", False, registry=reg, scope=SCOPE_TENANT, scope_id="acme")
126
134
 
135
+ await finalize_session(db_session)
127
136
  views = await svc.list_flags(reg, tenant_id="acme")
128
137
 
129
138
  view = next(v for v in views if v.name == "beta_ui")
@@ -138,6 +147,7 @@ class TestFeatureFlagServiceTenant:
138
147
  reg = _registry_with(("beta_ui", "", False))
139
148
  await svc.set_override("beta_ui", True, registry=reg) # system on
140
149
 
150
+ await finalize_session(db_session)
141
151
  views = await svc.list_flags(reg, tenant_id="acme")
142
152
 
143
153
  view = next(v for v in views if v.name == "beta_ui")
@@ -0,0 +1,34 @@
1
+ """Tenant deletion purges tenant overrides without touching other scopes."""
2
+
3
+ from feature_flags.constants import SCOPE_SYSTEM, SCOPE_TENANT
4
+ from feature_flags.models import FeatureFlagOverride
5
+ from feature_flags.module import FeatureFlagsModule
6
+ from simple_module_core.durable_events import DurableContext, event_key
7
+ from simple_module_core.events import EventBus
8
+ from simple_module_core.tenant_events import TenantDeleted
9
+ from sqlmodel import select
10
+
11
+
12
+ async def test_durable_tenant_cleanup_is_scoped_idempotent_and_transactional(db_session):
13
+ bus = EventBus()
14
+ FeatureFlagsModule().register_event_handlers(bus)
15
+ names = bus.durable.handler_names(TenantDeleted)
16
+ assert len(names) == 1
17
+ subscription = bus.durable.lookup(event_key(TenantDeleted), names[0])
18
+ assert subscription is not None
19
+ scopes = [(SCOPE_TENANT, "deleted"), (SCOPE_TENANT, "other"), (SCOPE_SYSTEM, "deleted")]
20
+ for scope, scope_id in scopes:
21
+ db_session.add(FeatureFlagOverride(scope=scope, scope_id=scope_id, name="test.flag"))
22
+ await db_session.commit()
23
+ event = TenantDeleted(tenant_id="deleted", slug="deleted", name="Deleted")
24
+ ctx = DurableContext(db=db_session, attempt=1)
25
+ await subscription.handler(event, ctx)
26
+ await db_session.rollback()
27
+ assert len((await db_session.execute(select(FeatureFlagOverride))).scalars().all()) == 3
28
+ await subscription.handler(event, ctx)
29
+ await subscription.handler(event, DurableContext(db=db_session, attempt=2))
30
+ rows = (await db_session.execute(select(FeatureFlagOverride))).scalars().all()
31
+ assert {(row.scope, row.scope_id) for row in rows} == {
32
+ (SCOPE_TENANT, "other"),
33
+ (SCOPE_SYSTEM, "deleted"),
34
+ }