mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat(settings): panel-tunable feature flags
Add a Feature Flags card to the Settings page that toggles env-gated subsystems (external/internal PR review, web research, strategy engine, pitch provisioning, RAG auto-update, transcript pruning) directly from the panel instead of hand-editing environment variables. Flags persist in system_settings as 'true'/'false' and are overlaid onto the live config singleton at startup; an unset flag keeps its environment/config default. A toggle takes effect on the next backend restart — no per-consumer re-routing. Backend: FEATURE_FLAGS registry + bool validator + get_bool accessor on SettingsService; feature_flag_effective_values and apply_persisted_feature_flags; GET /settings/feature-flags; best-effort startup overlay in the app lifespan. Frontend: settingsApi.getFeatureFlags / setFeatureFlag and a FeatureFlagsCard rendered full-width below the settings grid.
This commit is contained in:
@@ -27,6 +27,7 @@ import {
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { API_URL, WS_URL } from "@/lib/constants";
|
import { API_URL, WS_URL } from "@/lib/constants";
|
||||||
import { TranscriptRetentionCard } from "@/components/settings/transcript-retention-card";
|
import { TranscriptRetentionCard } from "@/components/settings/transcript-retention-card";
|
||||||
|
import { FeatureFlagsCard } from "@/components/settings/feature-flags-card";
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
@@ -241,6 +242,10 @@ export default function SettingsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Feature Flags — master switches for optional subsystems (full width;
|
||||||
|
persisted server-side, applied on next restart). */}
|
||||||
|
<FeatureFlagsCard />
|
||||||
|
|
||||||
{/* Save Button */}
|
{/* Save Button */}
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button onClick={handleSave}>
|
<Button onClick={handleSave}>
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { settingsApi } from "@/lib/api";
|
||||||
|
import type { FeatureFlag } from "@/lib/api/settings";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { Flag } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
// One-line blurb per flag so the operator knows what each master switch gates.
|
||||||
|
const FLAG_DESCRIPTIONS: Record<string, string> = {
|
||||||
|
external_pr_enabled: "Discover and review inbound external/fork pull requests.",
|
||||||
|
internal_pr_enabled: "Run the read-only safety reviewer on internal branch PRs.",
|
||||||
|
research_enabled: "Let the Board and PMs run web research.",
|
||||||
|
strategy_engine_enabled: "Generate and maintain company strategy artifacts.",
|
||||||
|
provisioning_enabled: "Auto-provision projects from approved pitches.",
|
||||||
|
rag_auto_update_enabled: "Keep the knowledge base index refreshed automatically.",
|
||||||
|
transcript_prune_enabled: "Run the background sweep that prunes old transcripts.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function FeatureFlagsCard() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ["feature-flags"],
|
||||||
|
queryFn: settingsApi.getFeatureFlags,
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleMutation = useMutation({
|
||||||
|
mutationFn: ({ key, enabled }: { key: string; enabled: boolean }) =>
|
||||||
|
settingsApi.setFeatureFlag(key, enabled),
|
||||||
|
onSuccess: (_data, { enabled }) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["feature-flags"] });
|
||||||
|
toast.success(
|
||||||
|
`Feature ${enabled ? "enabled" : "disabled"} — takes effect on next restart`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(
|
||||||
|
`Failed to update: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const flags: FeatureFlag[] = data?.flags ?? [];
|
||||||
|
const note = data?.note ?? "Changes take effect on the next backend restart.";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Flag className="h-5 w-5" />
|
||||||
|
Feature Flags
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Master switches for optional subsystems. Unset flags fall back to the
|
||||||
|
environment default. {note}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{isLoading && (
|
||||||
|
<p className="text-sm text-muted-foreground">Loading feature flags…</p>
|
||||||
|
)}
|
||||||
|
{!isLoading && flags.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">No feature flags available.</p>
|
||||||
|
)}
|
||||||
|
{flags.map((flag, i) => (
|
||||||
|
<div key={flag.key}>
|
||||||
|
{i > 0 && <Separator className="mb-4" />}
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<Label htmlFor={`flag-${flag.key}`}>{flag.label}</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{FLAG_DESCRIPTIONS[flag.key] ?? flag.key}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id={`flag-${flag.key}`}
|
||||||
|
checked={flag.enabled}
|
||||||
|
disabled={toggleMutation.isPending}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
toggleMutation.mutate({ key: flag.key, enabled: checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,17 @@ export interface SettingsResponse {
|
|||||||
settings: Record<string, string>;
|
settings: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FeatureFlag {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeatureFlagsResponse {
|
||||||
|
flags: FeatureFlag[];
|
||||||
|
note: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const settingsApi = {
|
export const settingsApi = {
|
||||||
// GET /api/settings — all runtime-editable settings as a flat key→value map.
|
// GET /api/settings — all runtime-editable settings as a flat key→value map.
|
||||||
getAll: async (): Promise<Record<string, string>> => {
|
getAll: async (): Promise<Record<string, string>> => {
|
||||||
@@ -15,4 +26,13 @@ export const settingsApi = {
|
|||||||
const { data } = await api.put<SettingsResponse>(`/settings/${key}`, { value });
|
const { data } = await api.put<SettingsResponse>(`/settings/${key}`, { value });
|
||||||
return data.settings;
|
return data.settings;
|
||||||
},
|
},
|
||||||
|
// GET /api/settings/feature-flags — effective flag values (override, else env).
|
||||||
|
getFeatureFlags: async (): Promise<FeatureFlagsResponse> => {
|
||||||
|
const { data } = await api.get<FeatureFlagsResponse>("/settings/feature-flags");
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
// PUT /api/settings/{key} — persist a feature flag as "true"/"false".
|
||||||
|
setFeatureFlag: async (key: string, enabled: boolean): Promise<void> => {
|
||||||
|
await api.put<SettingsResponse>(`/settings/${key}`, { value: enabled ? "true" : "false" });
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+14
-1
@@ -56,11 +56,12 @@ from roboco.api.routes.v1 import flow_qa as flow_qa_module
|
|||||||
from roboco.api.routes.work_session import router as work_session_router
|
from roboco.api.routes.work_session import router as work_session_router
|
||||||
from roboco.api.websocket import router as ws_router
|
from roboco.api.websocket import router as ws_router
|
||||||
from roboco.config import settings
|
from roboco.config import settings
|
||||||
from roboco.db.base import close_db, init_db
|
from roboco.db.base import close_db, get_session_factory, init_db
|
||||||
from roboco.logging import get_logger, setup_logging
|
from roboco.logging import get_logger, setup_logging
|
||||||
from roboco.services.extraction import ExtractionPipeline, ExtractionService
|
from roboco.services.extraction import ExtractionPipeline, ExtractionService
|
||||||
from roboco.services.learning import get_learning_service
|
from roboco.services.learning import get_learning_service
|
||||||
from roboco.services.optimal import close_optimal_service, get_optimal_service
|
from roboco.services.optimal import close_optimal_service, get_optimal_service
|
||||||
|
from roboco.services.settings import apply_persisted_feature_flags
|
||||||
from roboco.services.transcription import TranscriptionService
|
from roboco.services.transcription import TranscriptionService
|
||||||
|
|
||||||
# Setup logging before anything else
|
# Setup logging before anything else
|
||||||
@@ -105,6 +106,18 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]:
|
|||||||
await init_db()
|
await init_db()
|
||||||
logger.info("Database initialized")
|
logger.info("Database initialized")
|
||||||
|
|
||||||
|
# Overlay panel-persisted feature-flag overrides onto the live config so the
|
||||||
|
# rest of startup (and the dispatch loops) read the panel's choices; unset
|
||||||
|
# flags keep their env/config default. Best-effort — a failure here must not
|
||||||
|
# block startup, the env defaults still apply.
|
||||||
|
try:
|
||||||
|
async with get_session_factory()() as _flags_db:
|
||||||
|
applied_flags = await apply_persisted_feature_flags(_flags_db)
|
||||||
|
if applied_flags:
|
||||||
|
logger.info("Applied persisted feature-flag overrides", flags=applied_flags)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Feature-flag overlay failed; using env defaults", error=str(e))
|
||||||
|
|
||||||
# Initialize Phase 2 services
|
# Initialize Phase 2 services
|
||||||
_AppServices.transcription = TranscriptionService()
|
_AppServices.transcription = TranscriptionService()
|
||||||
await _AppServices.transcription.start()
|
await _AppServices.transcription.start()
|
||||||
|
|||||||
@@ -8,8 +8,18 @@ and are read by the backend (e.g. the transcript-retention prune sweep) with a
|
|||||||
from fastapi import APIRouter, HTTPException, status
|
from fastapi import APIRouter, HTTPException, status
|
||||||
|
|
||||||
from roboco.api.deps import DbSession
|
from roboco.api.deps import DbSession
|
||||||
from roboco.api.schemas.settings import SettingsResponse, SettingUpdate
|
from roboco.api.schemas.settings import (
|
||||||
from roboco.services.settings import SettingValidationError, get_settings_service
|
FeatureFlag,
|
||||||
|
FeatureFlagsResponse,
|
||||||
|
SettingsResponse,
|
||||||
|
SettingUpdate,
|
||||||
|
)
|
||||||
|
from roboco.services.settings import (
|
||||||
|
FEATURE_FLAGS,
|
||||||
|
SettingValidationError,
|
||||||
|
feature_flag_effective_values,
|
||||||
|
get_settings_service,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -20,6 +30,18 @@ async def list_settings(db: DbSession) -> SettingsResponse:
|
|||||||
return SettingsResponse(settings=await get_settings_service(db).all())
|
return SettingsResponse(settings=await get_settings_service(db).all())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/feature-flags", response_model=FeatureFlagsResponse)
|
||||||
|
async def get_feature_flags(db: DbSession) -> FeatureFlagsResponse:
|
||||||
|
"""Effective feature-flag values (stored override, else the env default)."""
|
||||||
|
effective = await feature_flag_effective_values(db)
|
||||||
|
return FeatureFlagsResponse(
|
||||||
|
flags=[
|
||||||
|
FeatureFlag(key=key, label=label, enabled=effective.get(key, False))
|
||||||
|
for key, label in FEATURE_FLAGS
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{key}", response_model=SettingsResponse)
|
@router.put("/{key}", response_model=SettingsResponse)
|
||||||
async def update_setting(
|
async def update_setting(
|
||||||
key: str, data: SettingUpdate, db: DbSession
|
key: str, data: SettingUpdate, db: DbSession
|
||||||
|
|||||||
@@ -15,3 +15,18 @@ class SettingsResponse(BaseModel):
|
|||||||
"""All runtime-editable settings as a flat key→value map."""
|
"""All runtime-editable settings as a flat key→value map."""
|
||||||
|
|
||||||
settings: dict[str, str] = Field(default_factory=dict)
|
settings: dict[str, str] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class FeatureFlag(BaseModel):
|
||||||
|
"""One panel-tunable feature flag and its current effective value."""
|
||||||
|
|
||||||
|
key: str
|
||||||
|
label: str
|
||||||
|
enabled: bool
|
||||||
|
|
||||||
|
|
||||||
|
class FeatureFlagsResponse(BaseModel):
|
||||||
|
"""Effective feature-flag values for the Settings panel (override, else env)."""
|
||||||
|
|
||||||
|
flags: list[FeatureFlag] = Field(default_factory=list)
|
||||||
|
note: str = "Changes take effect on the next backend restart."
|
||||||
|
|||||||
@@ -34,10 +34,32 @@ def _validate_retention_days(value: str) -> None:
|
|||||||
raise SettingValidationError("transcript_retention_days must be >= 1")
|
raise SettingValidationError("transcript_retention_days must be >= 1")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_bool(value: str) -> None:
|
||||||
|
if value.strip().lower() not in ("true", "false"):
|
||||||
|
raise SettingValidationError("value must be 'true' or 'false'")
|
||||||
|
|
||||||
|
|
||||||
|
# Panel-tunable feature flags (master switches). The stored value overrides the
|
||||||
|
# config/env default at startup via ``apply_persisted_feature_flags`` — i.e. a
|
||||||
|
# toggle takes effect on the next restart, replacing hand-editing env. Each maps
|
||||||
|
# to a ``roboco.config.Settings`` bool attribute of the same name.
|
||||||
|
FEATURE_FLAGS: tuple[tuple[str, str], ...] = (
|
||||||
|
("external_pr_enabled", "External-PR review"),
|
||||||
|
("internal_pr_enabled", "Internal-PR safety reviewer"),
|
||||||
|
("research_enabled", "Web research (Board + PM)"),
|
||||||
|
("strategy_engine_enabled", "Strategy engine"),
|
||||||
|
("provisioning_enabled", "Pitch auto-provisioning"),
|
||||||
|
("rag_auto_update_enabled", "RAG auto-update"),
|
||||||
|
("transcript_prune_enabled", "Transcript pruning"),
|
||||||
|
)
|
||||||
|
_FEATURE_FLAG_KEYS = tuple(key for key, _ in FEATURE_FLAGS)
|
||||||
|
|
||||||
|
|
||||||
# Writable settings: key -> validator. Keys absent here are rejected on write so
|
# Writable settings: key -> validator. Keys absent here are rejected on write so
|
||||||
# the panel can only persist values the backend understands.
|
# the panel can only persist values the backend understands.
|
||||||
_VALIDATORS = {
|
_VALIDATORS = {
|
||||||
"transcript_retention_days": _validate_retention_days,
|
"transcript_retention_days": _validate_retention_days,
|
||||||
|
**dict.fromkeys(_FEATURE_FLAG_KEYS, _validate_bool),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -69,6 +91,13 @@ class SettingsService(BaseService):
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
return default
|
return default
|
||||||
|
|
||||||
|
async def get_bool(self, key: str, default: bool) -> bool:
|
||||||
|
"""Return ``key`` parsed as a bool ('true'/'false'), or ``default``."""
|
||||||
|
raw = await self.get(key)
|
||||||
|
if raw is None:
|
||||||
|
return default
|
||||||
|
return raw.strip().lower() == "true"
|
||||||
|
|
||||||
async def set(self, key: str, value: str) -> None:
|
async def set(self, key: str, value: str) -> None:
|
||||||
"""Validate then upsert ``key`` = ``value``. Caller commits."""
|
"""Validate then upsert ``key`` = ``value``. Caller commits."""
|
||||||
validate_setting(key, value)
|
validate_setting(key, value)
|
||||||
@@ -88,3 +117,40 @@ class SettingsService(BaseService):
|
|||||||
def get_settings_service(session: AsyncSession) -> SettingsService:
|
def get_settings_service(session: AsyncSession) -> SettingsService:
|
||||||
"""Construct a SettingsService bound to ``session``."""
|
"""Construct a SettingsService bound to ``session``."""
|
||||||
return SettingsService(session)
|
return SettingsService(session)
|
||||||
|
|
||||||
|
|
||||||
|
async def feature_flag_effective_values(session: AsyncSession) -> dict[str, bool]:
|
||||||
|
"""Effective value of each panel-tunable flag: stored override, else env default.
|
||||||
|
|
||||||
|
Backs the Settings panel's feature-flag card so it shows what's actually in
|
||||||
|
force (the env/config default unless the panel has persisted an override).
|
||||||
|
"""
|
||||||
|
from roboco.config import settings as _settings
|
||||||
|
|
||||||
|
service = get_settings_service(session)
|
||||||
|
return {
|
||||||
|
key: await service.get_bool(key, bool(getattr(_settings, key, False)))
|
||||||
|
for key in _FEATURE_FLAG_KEYS
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def apply_persisted_feature_flags(session: AsyncSession) -> list[str]:
|
||||||
|
"""Overlay panel-persisted feature-flag overrides onto the live config.
|
||||||
|
|
||||||
|
Called once at startup, after the DB is ready: for each known flag with a
|
||||||
|
stored value, set the matching attribute on the ``roboco.config.settings``
|
||||||
|
singleton so the rest of the app reads the panel's choice. No per-consumer
|
||||||
|
re-routing — a toggle simply takes effect on the next restart. Returns the
|
||||||
|
keys that were overridden.
|
||||||
|
"""
|
||||||
|
from roboco.config import settings as _settings
|
||||||
|
|
||||||
|
service = get_settings_service(session)
|
||||||
|
applied: list[str] = []
|
||||||
|
for key in _FEATURE_FLAG_KEYS:
|
||||||
|
raw = await service.get(key)
|
||||||
|
if raw is None:
|
||||||
|
continue
|
||||||
|
setattr(_settings, key, raw.strip().lower() == "true")
|
||||||
|
applied.append(key)
|
||||||
|
return applied
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""Panel-tunable feature flags — validation, bool read, and startup overlay (#9).
|
||||||
|
|
||||||
|
Flags persist in system_settings as 'true'/'false' and are overlaid onto the
|
||||||
|
config singleton at startup; an unset flag keeps its env/config default.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from roboco.config import settings as cfg
|
||||||
|
from roboco.services import settings as settings_mod
|
||||||
|
from roboco.services.settings import (
|
||||||
|
SettingsService,
|
||||||
|
SettingValidationError,
|
||||||
|
validate_setting,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_feature_flags_are_writable_as_bool() -> None:
|
||||||
|
validate_setting("external_pr_enabled", "true")
|
||||||
|
validate_setting("research_enabled", "FALSE") # case-insensitive
|
||||||
|
validate_setting("internal_pr_enabled", " true ") # whitespace tolerated
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_bool_value_rejected() -> None:
|
||||||
|
with pytest.raises(SettingValidationError):
|
||||||
|
validate_setting("external_pr_enabled", "yes")
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_key_rejected() -> None:
|
||||||
|
with pytest.raises(SettingValidationError):
|
||||||
|
validate_setting("not_a_real_flag", "true")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_bool_parses_and_defaults() -> None:
|
||||||
|
svc = SettingsService(MagicMock())
|
||||||
|
object.__setattr__(svc, "get", AsyncMock(return_value="true"))
|
||||||
|
assert await svc.get_bool("k", default=False) is True
|
||||||
|
object.__setattr__(svc, "get", AsyncMock(return_value="false"))
|
||||||
|
assert await svc.get_bool("k", default=True) is False
|
||||||
|
object.__setattr__(svc, "get", AsyncMock(return_value=None))
|
||||||
|
assert await svc.get_bool("k", default=True) is True # unset → default
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_apply_overrides_stored_flags_only(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
# Baseline env defaults.
|
||||||
|
monkeypatch.setattr(cfg, "external_pr_enabled", False)
|
||||||
|
monkeypatch.setattr(cfg, "research_enabled", True)
|
||||||
|
|
||||||
|
# Only external_pr_enabled has a stored override; research_enabled is unset.
|
||||||
|
stored = {"external_pr_enabled": "true"}
|
||||||
|
|
||||||
|
async def fake_get(_self: SettingsService, key: str) -> str | None:
|
||||||
|
return stored.get(key)
|
||||||
|
|
||||||
|
monkeypatch.setattr(SettingsService, "get", fake_get)
|
||||||
|
applied = await settings_mod.apply_persisted_feature_flags(MagicMock())
|
||||||
|
|
||||||
|
assert "external_pr_enabled" in applied
|
||||||
|
assert cfg.external_pr_enabled is True # stored override applied
|
||||||
|
assert cfg.research_enabled is True # unset → env default untouched
|
||||||
|
assert "research_enabled" not in applied
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_effective_values_use_env_default_when_unset(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
monkeypatch.setattr(cfg, "strategy_engine_enabled", True)
|
||||||
|
|
||||||
|
async def fake_get(_self: SettingsService, _key: str) -> str | None:
|
||||||
|
return None # nothing stored
|
||||||
|
|
||||||
|
monkeypatch.setattr(SettingsService, "get", fake_get)
|
||||||
|
effective = await settings_mod.feature_flag_effective_values(MagicMock())
|
||||||
|
assert effective["strategy_engine_enabled"] is True # falls back to env
|
||||||
Reference in New Issue
Block a user