Files
multica/server/pkg/featureflag/service_test.go
Eve 1fee14bd2c feat(featureflag): framework-level feature flag system (MUL-3615)
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>
2026-06-24 13:11:39 +08:00

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)
}
}