mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 13:29:44 +02:00
Introduces a reusable feature flag framework so future features can adopt flags without writing infrastructure code. Backend: server/pkg/featureflag (Go) - Service / Provider / Decision separation per Martin Fowler's Toggle Point / Toggle Router / Toggle Configuration pattern. - Providers: StaticProvider (rules in source control), EnvProvider (FF_<KEY> overrides for ops kill switches), ChainProvider (first-hit-wins composition). - EvalContext carried through context.Context with WithEvalContext / EvalContextFrom; supports user_id, workspace_id, free-form attributes. - PercentRollout via deterministic FNV-1a bucketing; same user always lands in the same bucket so experiments do not flap between requests. - Nil-safe Service: a nil *Service or missing flag returns the caller's default so business code never panics on a missing flag. - 100% unit-test coverage with -race; go vet clean. Frontend: packages/core/feature-flags (TypeScript) - Same vocabulary as the Go side (Decision, EvalContext, Rule, PercentRollout). FNV-1a parity ensures cross-tier bucket agreement. - FeatureFlagService + StaticProvider + ChainProvider in pure TS. - React glue: FeatureFlagsProvider, useFlag(key, default), useVariant(key, default). Hooks fall back to the default when no provider is mounted so Storybook / unit tests stay simple. - Vitest tests for service, providers, hash, and React hooks. Docs: docs/feature-flags.md — wiring, EvalContext, toggle points, backend-protection note, and the standard best-practice checklist. The framework intentionally has no third-party Go deps and no API surface beyond what real callers will need. New providers (DB, remote config, LaunchDarkly) plug in by implementing Provider; no existing caller has to change. Co-authored-by: multica-agent <github@multica.ai>
84 lines
2.6 KiB
Go
84 lines
2.6 KiB
Go
package featureflag
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
)
|
|
|
|
func TestServiceNilSafe(t *testing.T) {
|
|
t.Parallel()
|
|
var s *Service
|
|
if s.IsEnabled(context.Background(), "anything", true) != true {
|
|
t.Fatalf("nil Service must honor the default")
|
|
}
|
|
if s.IsEnabled(context.Background(), "anything", false) != false {
|
|
t.Fatalf("nil Service must honor the default")
|
|
}
|
|
if got := s.Variant(context.Background(), "anything", "control"); got != "control" {
|
|
t.Fatalf("nil Service must return the variant default, got %q", got)
|
|
}
|
|
d := s.Decision(context.Background(), "anything", false)
|
|
if d.Reason != ReasonDefault || d.Source != "default" {
|
|
t.Fatalf("nil Service must return ReasonDefault, got %+v", d)
|
|
}
|
|
}
|
|
|
|
func TestServiceNilProvider(t *testing.T) {
|
|
t.Parallel()
|
|
s := NewService(nil)
|
|
if got := s.IsEnabled(context.Background(), "missing", true); got != true {
|
|
t.Fatalf("nil provider must honor the default")
|
|
}
|
|
d := s.Decision(context.Background(), "missing", false)
|
|
if d.Reason != ReasonDefault {
|
|
t.Fatalf("expected ReasonDefault, got %s", d.Reason)
|
|
}
|
|
}
|
|
|
|
func TestServiceUsesProvider(t *testing.T) {
|
|
t.Parallel()
|
|
sp := NewStaticProvider()
|
|
sp.Set("billing_new_invoice_email", Rule{Default: true})
|
|
s := NewService(sp)
|
|
|
|
if !s.IsEnabled(context.Background(), "billing_new_invoice_email", false) {
|
|
t.Fatalf("static provider should override the false default")
|
|
}
|
|
d := s.Decision(context.Background(), "billing_new_invoice_email", false)
|
|
if d.Reason != ReasonStatic || d.Source != "static" {
|
|
t.Fatalf("expected ReasonStatic from static source, got %+v", d)
|
|
}
|
|
if d.Key != "billing_new_invoice_email" {
|
|
t.Fatalf("decision must echo the requested key, got %q", d.Key)
|
|
}
|
|
}
|
|
|
|
func TestServiceMissingKeyReturnsDefault(t *testing.T) {
|
|
t.Parallel()
|
|
sp := NewStaticProvider()
|
|
sp.Set("known", Rule{Default: true})
|
|
s := NewService(sp)
|
|
|
|
if s.IsEnabled(context.Background(), "unknown", false) {
|
|
t.Fatalf("unknown key must honor the default")
|
|
}
|
|
d := s.Decision(context.Background(), "unknown", true)
|
|
if d.Reason != ReasonDefault || d.Enabled != true || d.Variant != "on" {
|
|
t.Fatalf("missing key did not produce default decision: %+v", d)
|
|
}
|
|
}
|
|
|
|
func TestServiceVariantFlag(t *testing.T) {
|
|
t.Parallel()
|
|
sp := NewStaticProvider()
|
|
sp.Set("checkout_algo", Rule{Default: true, Variant: "experiment-v2"})
|
|
s := NewService(sp)
|
|
|
|
if got := s.Variant(context.Background(), "checkout_algo", "control"); got != "experiment-v2" {
|
|
t.Fatalf("expected experiment-v2, got %q", got)
|
|
}
|
|
if got := s.Variant(context.Background(), "unknown_algo", "control"); got != "control" {
|
|
t.Fatalf("missing key must fall through to variant default, got %q", got)
|
|
}
|
|
}
|