simple-module-feature-flags 0.0.37__tar.gz → 0.0.39__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 (38) hide show
  1. {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/.gitignore +1 -2
  2. {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/PKG-INFO +5 -5
  3. {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/README.md +1 -1
  4. {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/feature_flags/contracts/schemas.py +1 -1
  5. {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/feature_flags/endpoints/views.py +3 -1
  6. simple_module_feature_flags-0.0.39/feature_flags/locales/de.json +56 -0
  7. {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/feature_flags/locales/en.json +2 -1
  8. {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/feature_flags/module.py +11 -5
  9. {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/feature_flags/pages/Browse.tsx +2 -4
  10. {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/feature_flags/service.py +27 -6
  11. simple_module_feature_flags-0.0.39/feature_flags/tenant_cleanup.py +18 -0
  12. {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/package.json +1 -1
  13. {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/pyproject.toml +4 -4
  14. simple_module_feature_flags-0.0.39/tests/test_feature_flags_tenant_cleanup.py +34 -0
  15. simple_module_feature_flags-0.0.39/tests/test_flag_description_i18n.py +38 -0
  16. simple_module_feature_flags-0.0.39/tests-js/feature_flags_Browse.test.tsx +195 -0
  17. {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/LICENSE +0 -0
  18. {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/feature_flags/__init__.py +0 -0
  19. {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/feature_flags/constants.py +0 -0
  20. {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/feature_flags/contracts/__init__.py +0 -0
  21. {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/feature_flags/deps.py +0 -0
  22. {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/feature_flags/endpoints/__init__.py +0 -0
  23. {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/feature_flags/endpoints/api.py +0 -0
  24. {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/feature_flags/models.py +0 -0
  25. {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/feature_flags/pages/components/FlagTable.tsx +0 -0
  26. {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/feature_flags/pages/components/ScopeCard.tsx +0 -0
  27. {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/feature_flags/pages/components/TenantPicker.tsx +0 -0
  28. {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/feature_flags/pages/components/ToggleConfirmDialog.tsx +0 -0
  29. {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/feature_flags/pages/types.ts +0 -0
  30. {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/feature_flags/py.typed +0 -0
  31. {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/tests/conftest.py +0 -0
  32. {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/tests/test_feature_flags_api.py +0 -0
  33. {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/tests/test_feature_flags_audit_links.py +0 -0
  34. {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/tests/test_feature_flags_menu_section.py +0 -0
  35. {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/tests/test_feature_flags_service.py +0 -0
  36. {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/tests/test_feature_flags_tenancy.py +0 -0
  37. {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/tests/test_store_outage_fallback.py +0 -0
  38. {simple_module_feature_flags-0.0.37 → simple_module_feature_flags-0.0.39}/tsconfig.json +0 -0
@@ -7,8 +7,6 @@ build/
7
7
  .venv/
8
8
  *.egg
9
9
 
10
- # UV
11
- uv.lock
12
10
 
13
11
  # Node
14
12
  node_modules/
@@ -65,3 +63,4 @@ host/client_app/.playwright-cli/*
65
63
  .qa/
66
64
  .verify/
67
65
  /openapi.json
66
+ qa-shots/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: simple_module_feature_flags
3
- Version: 0.0.37
3
+ Version: 0.0.39
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.37
25
- Requires-Dist: simple-module-db==0.0.37
26
- Requires-Dist: simple-module-hosting==0.0.37
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
27
27
  Description-Content-Type: text/markdown
28
28
 
29
29
  # simple_module_feature_flags
@@ -61,7 +61,7 @@ Gate a route using the registry dependency:
61
61
  ```python
62
62
  from fastapi import APIRouter, HTTPException
63
63
 
64
- from feature_flags.deps import FeatureFlagRegistryDep # type: ignore[import-not-found]
64
+ from feature_flags.deps import FeatureFlagRegistryDep
65
65
 
66
66
  router = APIRouter()
67
67
 
@@ -33,7 +33,7 @@ Gate a route using the registry dependency:
33
33
  ```python
34
34
  from fastapi import APIRouter, HTTPException
35
35
 
36
- from feature_flags.deps import FeatureFlagRegistryDep # type: ignore[import-not-found]
36
+ from feature_flags.deps import FeatureFlagRegistryDep
37
37
 
38
38
  router = APIRouter()
39
39
 
@@ -11,7 +11,7 @@ from sqlmodel import Field, SQLModel
11
11
  class FeatureFlagOverrideOut(SQLModel):
12
12
  """Stored override row as returned by the service/API."""
13
13
 
14
- model_config = ConfigDict(from_attributes=True)
14
+ model_config = ConfigDict(from_attributes=True) # ty: ignore[invalid-assignment]
15
15
 
16
16
  id: int
17
17
  scope: str
@@ -9,6 +9,7 @@ frontend doesn't need separate routes per scope.
9
9
  from __future__ import annotations
10
10
 
11
11
  from fastapi import APIRouter, Depends, Request
12
+ from simple_module_hosting.i18n_deps import TranslatorDep
12
13
  from simple_module_hosting.inertia_deps import InertiaDep
13
14
  from simple_module_hosting.permissions import RequiresPermission
14
15
  from simple_module_inertia import InertiaResponse
@@ -75,9 +76,10 @@ async def browse(
75
76
  inertia: InertiaDep,
76
77
  service: FeatureFlagServiceDep,
77
78
  registry: FeatureFlagRegistryDep,
79
+ translator: TranslatorDep,
78
80
  tenant_id: str | None = None,
79
81
  ) -> InertiaResponse:
80
- flags = await service.list_flags(registry, tenant_id=tenant_id)
82
+ flags = await service.list_flags(registry, tenant_id=tenant_id, translate=translator.t)
81
83
  tenants = await service.list_tenants_with_overrides()
82
84
  return await inertia.render(
83
85
  PAGE_BROWSE,
@@ -0,0 +1,56 @@
1
+ {
2
+ "browse": {
3
+ "title": "Funktionsschalter",
4
+ "description": "Laufzeitschalter. Eine Organisationsüberschreibung hat Vorrang vor dem Systemwert.",
5
+ "empty_title": "Keine Funktionsschalter registriert",
6
+ "empty_description": "Module deklarieren Schalter über register_feature_flags(). Installieren Sie eines, um sie hier zu sehen.",
7
+ "scope_label": "Geltungsbereich",
8
+ "viewing_system": "Systemweite Schalter — gelten für jede Organisation, sofern sie nicht überschrieben werden.",
9
+ "viewing_tenant": "Überschreibungen für Organisation {tenant_id} — nicht gesetzte Schalter verwenden den Systemwert.",
10
+ "view_history": "Änderungsverlauf anzeigen →",
11
+ "audit_note": "Jede Umschaltung wird mit Akteur und vorherigem Wert im Änderungsprotokoll erfasst.",
12
+ "tenant_id_label": "Organisations-ID",
13
+ "tenant_id_placeholder": "z. B. acme",
14
+ "scope_system": "System",
15
+ "scope_custom": "Andere…",
16
+ "go": "Anzeigen",
17
+ "cancel": "Abbrechen"
18
+ },
19
+ "table": {
20
+ "name": "Name",
21
+ "description": "Beschreibung",
22
+ "system": "System",
23
+ "effective": "Wirksam",
24
+ "actions": "Aktionen",
25
+ "on": "ein",
26
+ "off": "aus",
27
+ "enabled": "Aktiviert",
28
+ "disabled": "Deaktiviert",
29
+ "overridden": "Überschreibung",
30
+ "following_default": "Standardwert gilt",
31
+ "following_system": "Systemwert gilt",
32
+ "clear_override": "Überschreibung entfernen"
33
+ },
34
+ "confirm": {
35
+ "enable_title": "„{name}“ aktivieren?",
36
+ "disable_title": "„{name}“ deaktivieren?",
37
+ "scope_system": "Dies ändert den Wert sofort für jede Organisation.",
38
+ "scope_tenant": "Dies setzt eine Überschreibung nur für die Organisation „{tenant_id}“. Andere Organisationen verwenden weiterhin den Systemwert.",
39
+ "enable_action": "Aktivieren",
40
+ "disable_action": "Deaktivieren",
41
+ "cancel": "Abbrechen"
42
+ },
43
+ "toasts": {
44
+ "enabled": "„{name}“ aktiviert",
45
+ "disabled": "„{name}“ deaktiviert",
46
+ "cleared": "Überschreibung für „{name}“ entfernt",
47
+ "toggle_failed": "Funktionsschalter konnte nicht aktualisiert werden"
48
+ },
49
+ "audit": {
50
+ "override": "Funktionsschalterüberschreibung"
51
+ },
52
+ "nav": {
53
+ "feature_flags": "Funktionsschalter"
54
+ },
55
+ "permission_group": "Feature-Flags"
56
+ }
@@ -51,5 +51,6 @@
51
51
  },
52
52
  "nav": {
53
53
  "feature_flags": "Feature flags"
54
- }
54
+ },
55
+ "permission_group": "Feature flags"
55
56
  }
@@ -29,6 +29,7 @@ from feature_flags.constants import (
29
29
  )
30
30
 
31
31
  if TYPE_CHECKING:
32
+ from simple_module_core.events import EventBus
32
33
  from sqlalchemy.ext.asyncio import AsyncSession
33
34
 
34
35
  _logger = logging.getLogger(__name__)
@@ -67,6 +68,13 @@ class FeatureFlagsModule(ModuleBase):
67
68
  i18n_audience="admin",
68
69
  )
69
70
 
71
+ def register_event_handlers(self, bus: EventBus, app: FastAPI | None = None) -> None:
72
+ from simple_module_core.tenant_events import TenantDeleted
73
+
74
+ from feature_flags.tenant_cleanup import purge_tenant_data
75
+
76
+ bus.subscribe_durable(TenantDeleted, purge_tenant_data)
77
+
70
78
  def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None:
71
79
  from feature_flags.endpoints.api import router as api
72
80
  from feature_flags.endpoints.views import router as views
@@ -103,14 +111,11 @@ class FeatureFlagsModule(ModuleBase):
103
111
  from feature_flags.models import FeatureFlagOverride
104
112
 
105
113
  registry.register(
106
- AuditLink(
107
- # Class name, not __tablename__ — see AuditLink.entity_type.
108
- # The table name travels alongside as the audit log's type tag.
109
- entity_type=FeatureFlagOverride.__name__,
114
+ AuditLink.for_model(
115
+ FeatureFlagOverride,
110
116
  url_template=f"{MENU_URL}?{QP_OVERRIDE}={{id}}",
111
117
  label=AUDIT_LINK_LABEL,
112
118
  label_key=AUDIT_LINK_LABEL_KEY,
113
- table_name=FeatureFlagOverride.__tablename__,
114
119
  label_resolver=_resolve_override_labels,
115
120
  )
116
121
  )
@@ -122,6 +127,7 @@ class FeatureFlagsModule(ModuleBase):
122
127
  PERM_FEATURE_FLAGS_VIEW,
123
128
  PERM_FEATURE_FLAGS_MANAGE,
124
129
  ],
130
+ label_key="feature_flags.permission_group",
125
131
  )
126
132
 
127
133
  def locale_dirs(self) -> dict[str, Path]:
@@ -1,4 +1,4 @@
1
- import { Head, router, usePage } from '@inertiajs/react';
1
+ import { Head, router } from '@inertiajs/react';
2
2
  import { keys, useT } from '@simple-module-py/i18n';
3
3
  import { PageShell } from '@simple-module-py/ui/components/PageShell';
4
4
  import { usePermissions } from '@simple-module-py/ui/hooks/use-permissions';
@@ -32,9 +32,7 @@ function actionUrl(name: string, action: 'toggle' | 'clear', tenantId: string |
32
32
  return tenantId ? `${base}?tenant_id=${encodeURIComponent(tenantId)}` : base;
33
33
  }
34
34
 
35
- function Browse() {
36
- const { flags, tenant_id, tenants, audit_log_url } = usePage<{ props: Props }>()
37
- .props as unknown as Props;
35
+ function Browse({ flags, tenant_id, tenants, audit_log_url }: Props) {
38
36
  const { t } = useT();
39
37
  const { can } = usePermissions();
40
38
  const canManage = can('feature_flags.manage');
@@ -7,9 +7,12 @@ mirrors mutations into the registry.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ from collections.abc import Callable
11
+
10
12
  from simple_module_core.feature_flags import FeatureFlagDefinition, FeatureFlagRegistry
11
13
  from sqlalchemy import select
12
14
  from sqlalchemy.ext.asyncio import AsyncSession
15
+ from sqlmodel import col
13
16
 
14
17
  from feature_flags.constants import SCOPE_SYSTEM, SCOPE_TENANT, SYSTEM_SCOPE_ID
15
18
  from feature_flags.contracts.schemas import FeatureFlagOverrideOut, FeatureFlagView
@@ -21,16 +24,25 @@ def _registry_tenant_id(scope: str, scope_id: str) -> str | None:
21
24
  return scope_id if scope == SCOPE_TENANT else None
22
25
 
23
26
 
27
+ def _description(flag: FeatureFlagDefinition, translate: Callable[[str], str] | None) -> str:
28
+ # Translator.t() echoes an unknown key back; keep the literal in that case.
29
+ if not flag.description_key or translate is None:
30
+ return flag.description
31
+ translated = translate(flag.description_key)
32
+ return flag.description if translated == flag.description_key else translated
33
+
34
+
24
35
  def _build_view(
25
36
  flag: FeatureFlagDefinition,
26
37
  *,
27
38
  enabled: bool,
28
39
  overridden: bool,
29
40
  system_enabled: bool | None = None,
41
+ translate: Callable[[str], str] | None = None,
30
42
  ) -> FeatureFlagView:
31
43
  return FeatureFlagView(
32
44
  name=flag.name,
33
- description=flag.description,
45
+ description=_description(flag, translate),
34
46
  default_enabled=flag.default_enabled,
35
47
  enabled=enabled,
36
48
  overridden=overridden,
@@ -73,7 +85,10 @@ class FeatureFlagService:
73
85
  return list(result.scalars())
74
86
 
75
87
  async def list_flags(
76
- self, registry: FeatureFlagRegistry, tenant_id: str | None = None
88
+ self,
89
+ registry: FeatureFlagRegistry,
90
+ tenant_id: str | None = None,
91
+ translate: Callable[[str], str] | None = None,
77
92
  ) -> list[FeatureFlagView]:
78
93
  """Join registered definitions with persisted overrides for admin display.
79
94
 
@@ -95,7 +110,12 @@ class FeatureFlagService:
95
110
  system_enabled = system_value if system_value is not None else flag.default_enabled
96
111
  if tenant_id is None:
97
112
  views.append(
98
- _build_view(flag, enabled=system_enabled, overridden=system_value is not None)
113
+ _build_view(
114
+ flag,
115
+ enabled=system_enabled,
116
+ overridden=system_value is not None,
117
+ translate=translate,
118
+ )
99
119
  )
100
120
  continue
101
121
  tenant_value = registry.tenant_override(flag.name, tenant_id)
@@ -105,6 +125,7 @@ class FeatureFlagService:
105
125
  enabled=tenant_value if tenant_value is not None else system_enabled,
106
126
  overridden=tenant_value is not None,
107
127
  system_enabled=system_enabled,
128
+ translate=translate,
108
129
  )
109
130
  )
110
131
  return views
@@ -131,9 +152,9 @@ class FeatureFlagService:
131
152
  async def _find(self, scope: str, scope_id: str, name: str) -> FeatureFlagOverride | None:
132
153
  result = await self.db.execute(
133
154
  select(FeatureFlagOverride).where(
134
- FeatureFlagOverride.scope == scope,
135
- FeatureFlagOverride.scope_id == scope_id,
136
- FeatureFlagOverride.name == name,
155
+ col(FeatureFlagOverride.scope) == scope,
156
+ col(FeatureFlagOverride.scope_id) == scope_id,
157
+ col(FeatureFlagOverride.name) == name,
137
158
  )
138
159
  )
139
160
  return result.scalar_one_or_none()
@@ -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.1.0",
3
+ "version": "0.0.39",
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.37"
3
+ version = "0.0.39"
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.37",
25
- "simple_module_db==0.0.37",
26
- "simple_module_hosting==0.0.37",
24
+ "simple_module_core==0.0.39",
25
+ "simple_module_db==0.0.39",
26
+ "simple_module_hosting==0.0.39",
27
27
  ]
28
28
 
29
29
  [project.entry-points.simple_module]
@@ -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
+ }
@@ -0,0 +1,38 @@
1
+ """Feature flag descriptions are translated server-side (TEST-016)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from feature_flags.service import FeatureFlagService
6
+ from simple_module_core.feature_flags import FeatureFlagDefinition, FeatureFlagRegistry
7
+ from simple_module_core.i18n import Translator
8
+
9
+ PUBLIC_UPLOADS = "file_storage.public_uploads"
10
+
11
+
12
+ def _translator(app, locale: str) -> Translator:
13
+ registry = app.state.sm.i18n_registry
14
+ if locale not in registry.supported_locales: # test hosts default to ["en"]
15
+ registry.supported_locales = [*registry.supported_locales, locale]
16
+ registry.load()
17
+ return Translator(registry, locale=locale, default_locale="en")
18
+
19
+
20
+ async def _description(app, db_session, locale: str) -> str:
21
+ svc = FeatureFlagService(db_session)
22
+ views = await svc.list_flags(app.state.sm.feature_flags, translate=_translator(app, locale).t)
23
+ return next(v.description for v in views if v.name == PUBLIC_UPLOADS)
24
+
25
+
26
+ async def test_description_follows_the_locale(app, db_session):
27
+ english = await _description(app, db_session, "en")
28
+ german = await _description(app, db_session, "de")
29
+ assert english == "Allow unauthenticated uploads (future)."
30
+ assert german != english
31
+ assert "Uploads" in german
32
+
33
+
34
+ async def test_unresolved_key_keeps_the_literal(db_session):
35
+ reg = FeatureFlagRegistry()
36
+ reg.add(FeatureFlagDefinition(name="x", description="Literal", description_key="nope.key"))
37
+ views = await FeatureFlagService(db_session).list_flags(reg, translate=lambda k: k)
38
+ assert views[0].description == "Literal"
@@ -0,0 +1,195 @@
1
+ import { configureI18n, keys } from '@simple-module-py/i18n';
2
+ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
3
+ import type { ComponentProps } from 'react';
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
+
6
+ configureI18n({ locale: 'en', messages: {} });
7
+
8
+ const state = vi.hoisted(() => ({ props: {} as Record<string, unknown> }));
9
+ const post = vi.hoisted(() => vi.fn());
10
+ const visit = vi.hoisted(() => vi.fn());
11
+
12
+ vi.mock('@inertiajs/react', () => ({
13
+ Head: () => null,
14
+ Link: ({ children, href }: { children: React.ReactNode; href: string }) => (
15
+ <a href={href}>{children}</a>
16
+ ),
17
+ router: { post, visit },
18
+ usePage: () => ({ props: state.props }),
19
+ }));
20
+ vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
21
+
22
+ import { toast } from 'sonner';
23
+ import Browse from '../feature_flags/pages/Browse';
24
+ import type { FeatureFlag } from '../feature_flags/pages/types';
25
+
26
+ const FLAGS: FeatureFlag[] = [
27
+ {
28
+ name: 'beta_search',
29
+ description: 'New search',
30
+ default_enabled: false,
31
+ enabled: false,
32
+ overridden: false,
33
+ system_enabled: null,
34
+ },
35
+ {
36
+ name: 'dark_mode',
37
+ description: 'Dark theme',
38
+ default_enabled: false,
39
+ enabled: true,
40
+ overridden: true,
41
+ system_enabled: null,
42
+ },
43
+ ];
44
+
45
+ function setProps(over: Record<string, unknown> = {}, permissions = ['feature_flags.manage']) {
46
+ state.props = {
47
+ flags: FLAGS,
48
+ tenant_id: null,
49
+ tenants: ['acme'],
50
+ audit_log_url: null,
51
+ auth: { permissions },
52
+ ...over,
53
+ };
54
+ }
55
+
56
+ type PostOptions = { onSuccess: () => void; onError: () => void; preserveScroll: boolean };
57
+
58
+ const confirm = keys.feature_flags.confirm;
59
+ const table = keys.feature_flags.table;
60
+ const browse = keys.feature_flags.browse;
61
+
62
+ describe('feature_flags Browse', () => {
63
+ beforeEach(() => setProps());
64
+ afterEach(() => {
65
+ vi.clearAllMocks();
66
+ });
67
+
68
+ it('renders every flag with its description and override badge', () => {
69
+ render(<Browse {...(state.props as ComponentProps<typeof Browse>)} />);
70
+ expect(screen.getByText('beta_search')).toBeInTheDocument();
71
+ expect(screen.getByText('dark_mode')).toBeInTheDocument();
72
+ expect(screen.getByText('Dark theme')).toBeInTheDocument();
73
+ expect(screen.getAllByText(table.overridden)).toHaveLength(1);
74
+ });
75
+
76
+ it('shows the empty state when no module registers flags', () => {
77
+ setProps({ flags: [] });
78
+ render(<Browse {...(state.props as ComponentProps<typeof Browse>)} />);
79
+ expect(screen.getByText(browse.empty_title)).toBeInTheDocument();
80
+ });
81
+
82
+ it('links to the audit history only when an audit log is installed', () => {
83
+ const { unmount } = render(<Browse {...(state.props as ComponentProps<typeof Browse>)} />);
84
+ expect(screen.queryByText(browse.view_history)).toBeNull();
85
+ unmount();
86
+ setProps({ audit_log_url: '/admin/audit?x=1' });
87
+ render(<Browse {...(state.props as ComponentProps<typeof Browse>)} />);
88
+ expect(screen.getByText(browse.view_history)).toHaveAttribute('href', '/admin/audit?x=1');
89
+ });
90
+
91
+ it('makes the switches read-only without the manage permission', () => {
92
+ setProps({}, []);
93
+ render(<Browse {...(state.props as ComponentProps<typeof Browse>)} />);
94
+ expect(screen.getByRole('switch', { name: 'beta_search' })).toBeDisabled();
95
+ expect(screen.queryByText(table.clear_override)).toBeNull();
96
+ });
97
+
98
+ it('stages a toggle behind a confirm and writes nothing until confirmed', async () => {
99
+ render(<Browse {...(state.props as ComponentProps<typeof Browse>)} />);
100
+ fireEvent.click(screen.getByRole('switch', { name: 'beta_search' }));
101
+ expect(await screen.findByText(confirm.scope_system)).toBeInTheDocument();
102
+ expect(post).not.toHaveBeenCalled();
103
+ });
104
+
105
+ it('cancelling the confirm leaves the flag and the server untouched', async () => {
106
+ render(<Browse {...(state.props as ComponentProps<typeof Browse>)} />);
107
+ fireEvent.click(screen.getByRole('switch', { name: 'beta_search' }));
108
+ fireEvent.click(await screen.findByRole('button', { name: confirm.cancel }));
109
+ await waitFor(() => expect(screen.queryByText(confirm.scope_system)).toBeNull());
110
+ expect(post).not.toHaveBeenCalled();
111
+ expect(screen.getByRole('switch', { name: 'beta_search' })).not.toBeChecked();
112
+ });
113
+
114
+ it('enables a flag on confirm, posting enabled=true to the system scope', async () => {
115
+ render(<Browse {...(state.props as ComponentProps<typeof Browse>)} />);
116
+ fireEvent.click(screen.getByRole('switch', { name: 'beta_search' }));
117
+ fireEvent.click(await screen.findByRole('button', { name: confirm.enable_action }));
118
+
119
+ expect(post).toHaveBeenCalledTimes(1);
120
+ const [url, data, options] = post.mock.calls[0] as [string, unknown, PostOptions];
121
+ expect(url).toBe('/admin/feature-flags/beta_search/toggle');
122
+ expect(data).toEqual({ enabled: true });
123
+ expect(options.preserveScroll).toBe(true);
124
+ options.onSuccess();
125
+ expect(toast.success).toHaveBeenCalledWith(keys.feature_flags.toasts.enabled);
126
+ });
127
+
128
+ it('disables a flag on confirm and toasts a failure from the server', async () => {
129
+ render(<Browse {...(state.props as ComponentProps<typeof Browse>)} />);
130
+ fireEvent.click(screen.getByRole('switch', { name: 'dark_mode' }));
131
+ fireEvent.click(await screen.findByRole('button', { name: confirm.disable_action }));
132
+
133
+ const [, data, options] = post.mock.calls[0] as [string, unknown, PostOptions];
134
+ expect(data).toEqual({ enabled: false });
135
+ options.onSuccess();
136
+ expect(toast.success).toHaveBeenCalledWith(keys.feature_flags.toasts.disabled);
137
+ options.onError();
138
+ expect(toast.error).toHaveBeenCalledWith(keys.feature_flags.toasts.toggle_failed);
139
+ });
140
+
141
+ it('scopes the toggle URL and the confirm text to the viewed tenant', async () => {
142
+ setProps({ tenant_id: 'acme corp' });
143
+ render(<Browse {...(state.props as ComponentProps<typeof Browse>)} />);
144
+ fireEvent.click(screen.getByRole('switch', { name: 'beta_search' }));
145
+ expect(await screen.findByText(confirm.scope_tenant)).toBeInTheDocument();
146
+ fireEvent.click(screen.getByRole('button', { name: confirm.enable_action }));
147
+ expect(post.mock.calls[0][0]).toBe(
148
+ '/admin/feature-flags/beta_search/toggle?tenant_id=acme%20corp',
149
+ );
150
+ });
151
+
152
+ it('clears an override without a confirm and reports the result', () => {
153
+ render(<Browse {...(state.props as ComponentProps<typeof Browse>)} />);
154
+ fireEvent.click(screen.getAllByText(table.clear_override)[0]);
155
+ const [url, data, options] = post.mock.calls[0] as [string, unknown, PostOptions];
156
+ expect(url).toBe('/admin/feature-flags/dark_mode/clear');
157
+ expect(data).toEqual({});
158
+ options.onSuccess();
159
+ expect(toast.success).toHaveBeenCalledWith(keys.feature_flags.toasts.cleared);
160
+ options.onError();
161
+ expect(toast.error).toHaveBeenCalledWith(keys.feature_flags.toasts.toggle_failed);
162
+ });
163
+
164
+ it('explains which value a non-overridden flag follows', () => {
165
+ render(<Browse {...(state.props as ComponentProps<typeof Browse>)} />);
166
+ expect(screen.getAllByText(table.following_default).length).toBeGreaterThan(0);
167
+ cleanup();
168
+ setProps({ tenant_id: 'acme' });
169
+ render(<Browse {...(state.props as ComponentProps<typeof Browse>)} />);
170
+ expect(screen.getAllByText(table.following_system).length).toBeGreaterThan(0);
171
+ });
172
+
173
+ it('switches scope by visiting the tenant URL, or the system URL', () => {
174
+ render(<Browse {...(state.props as ComponentProps<typeof Browse>)} />);
175
+ fireEvent.click(screen.getByRole('radio', { name: 'acme' }));
176
+ expect(visit).toHaveBeenCalledWith('/admin/feature-flags/?tenant_id=acme');
177
+ cleanup();
178
+ setProps({ tenant_id: 'acme' });
179
+ render(<Browse {...(state.props as ComponentProps<typeof Browse>)} />);
180
+ fireEvent.click(screen.getByRole('radio', { name: browse.scope_system }));
181
+ expect(visit).toHaveBeenLastCalledWith('/admin/feature-flags/');
182
+ });
183
+
184
+ it('lets an operator name a tenant with no overrides yet', () => {
185
+ render(<Browse {...(state.props as ComponentProps<typeof Browse>)} />);
186
+ fireEvent.click(screen.getByRole('radio', { name: browse.scope_custom }));
187
+ const go = screen.getByRole('button', { name: browse.go });
188
+ expect(go).toBeDisabled();
189
+ fireEvent.change(screen.getByLabelText(browse.tenant_id_label), {
190
+ target: { value: ' new-co ' },
191
+ });
192
+ fireEvent.click(go);
193
+ expect(visit).toHaveBeenCalledWith('/admin/feature-flags/?tenant_id=new-co');
194
+ });
195
+ });