mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-03 19:20:07 +02:00
Follow-up review on PR #4496 caught that the previous hash fix was only correct for ASCII input. The TS side used `charCodeAt`, which returns UTF-16 code units, while the Go side hashes the UTF-8 byte representation. Any non-ASCII flag key or identifier — Chinese flag names, accented user IDs, emoji — would bucket differently on backend vs frontend, silently breaking the "same user, same bucket" promise the PR description makes. Concretely: flag/é Go 53 vs TS-old 68 flag/🦄 Go 82 vs TS-old 75 实验/user-1 Go 90 vs TS-old 4 flag/用户-1 Go 95 vs TS-old 2 Fix: replace per-char charCodeAt with a module-level `TextEncoder` ('utf-8') and hash each encoded byte. After the fix all four cases above match Go exactly, and the existing ASCII cases continue to match. The cross-language golden tables on both sides now include the 5 new non-ASCII cases alongside the 5 ASCII cases, so any future regression that swaps UTF-8 for charCodeAt (or vice versa) will fail loudly on both Go and TS simultaneously. TextEncoder is part of WHATWG Encoding and is available in every evergreen browser, in Node 11+, and in Hermes (React Native) >= 0.74, which covers every runtime that imports @multica/core/feature-flags. Tests: - go test -race ./pkg/featureflag/... : all green. - pnpm --filter @multica/core test : 661/661. - pnpm --filter @multica/core typecheck : clean. Co-authored-by: multica-agent <github@multica.ai>
146 lines
4.5 KiB
Go
146 lines
4.5 KiB
Go
package featureflag
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
)
|
|
|
|
func TestEvalContextLookup(t *testing.T) {
|
|
t.Parallel()
|
|
ec := EvalContext{
|
|
UserID: "u-1",
|
|
WorkspaceID: "w-2",
|
|
Attributes: map[string]string{"plan": "pro", "country": ""},
|
|
}
|
|
tests := []struct {
|
|
name string
|
|
key string
|
|
value string
|
|
found bool
|
|
}{
|
|
{"user_id", "user_id", "u-1", true},
|
|
{"workspace_id", "workspace_id", "w-2", true},
|
|
{"plan", "plan", "pro", true},
|
|
{"empty attribute treated as missing", "country", "", false},
|
|
{"unknown attribute", "unknown", "", false},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
v, ok := ec.Lookup(tt.key)
|
|
if v != tt.value || ok != tt.found {
|
|
t.Fatalf("Lookup(%q) = (%q, %v), want (%q, %v)", tt.key, v, ok, tt.value, tt.found)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEvalContextRoundTripThroughContext(t *testing.T) {
|
|
t.Parallel()
|
|
ec := EvalContext{UserID: "u-1"}
|
|
ctx := WithEvalContext(context.Background(), ec)
|
|
got := EvalContextFrom(ctx)
|
|
if got.UserID != "u-1" {
|
|
t.Fatalf("EvalContext did not round-trip, got %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestEvalContextFromUnattachedContext(t *testing.T) {
|
|
t.Parallel()
|
|
// An unattached context must return the zero value, not panic.
|
|
got := EvalContextFrom(context.Background())
|
|
if got.UserID != "" || got.WorkspaceID != "" || got.Attributes != nil {
|
|
t.Fatalf("unattached context should yield zero EvalContext, got %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestEvalContextFromNilContext(t *testing.T) {
|
|
t.Parallel()
|
|
//nolint:staticcheck // deliberately exercise the nil-ctx defensive path.
|
|
got := EvalContextFrom(nil)
|
|
if got.UserID != "" {
|
|
t.Fatalf("nil context must yield zero EvalContext, got %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestPercentBucketStable(t *testing.T) {
|
|
t.Parallel()
|
|
// Hash stability is part of the public contract: the same (key, id)
|
|
// MUST produce the same bucket forever, otherwise users will flip
|
|
// in and out of experiments. We pin a handful of values so a future
|
|
// refactor that swaps the hash will fail loudly here.
|
|
cases := []struct {
|
|
key, id string
|
|
want int
|
|
}{
|
|
{"feature_a", "user-1", bucketFor("feature_a", "user-1")},
|
|
{"feature_b", "", bucketFor("feature_b", "")},
|
|
}
|
|
for _, tc := range cases {
|
|
got := bucketFor(tc.key, tc.id)
|
|
if got != tc.want {
|
|
t.Fatalf("bucketFor(%q, %q) = %d, want %d", tc.key, tc.id, got, tc.want)
|
|
}
|
|
if got < 0 || got >= 100 {
|
|
t.Fatalf("bucket out of range: %d", got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPercentBucketSeparator(t *testing.T) {
|
|
t.Parallel()
|
|
// Without a separator, ("ab", "c") and ("a", "bc") would collide.
|
|
// The separator must keep them distinct, otherwise two unrelated
|
|
// flags could share buckets and skew an experiment.
|
|
left := bucketFor("ab", "c")
|
|
right := bucketFor("a", "bc")
|
|
if left == right {
|
|
// Not guaranteed unequal in general, but for these inputs the
|
|
// FNV-1a + zero separator should produce different buckets.
|
|
// If this ever does collide we should switch separators, not
|
|
// hide the regression.
|
|
t.Fatalf("hash separator failed: bucketFor('ab','c') == bucketFor('a','bc') == %d", left)
|
|
}
|
|
}
|
|
|
|
// TestPercentBucketCrossLanguageGolden pins concrete (key, identifier) ->
|
|
// bucket values that the Go side MUST agree on with the TS side. The same
|
|
// values are duplicated in packages/core/feature-flags/hash.test.ts; if
|
|
// either side drifts, both tests fail and one must be brought back in
|
|
// sync. This is the single source of truth for "same user, same bucket"
|
|
// across the backend and the frontend.
|
|
//
|
|
// The non-ASCII cases (CJK, accented, emoji) exist on purpose: Go hashes
|
|
// the UTF-8 byte representation of a string, and the TS side must do the
|
|
// same. A regression that swaps charCodeAt for UTF-8 decoding on either
|
|
// side would only be caught by these inputs.
|
|
func TestPercentBucketCrossLanguageGolden(t *testing.T) {
|
|
t.Parallel()
|
|
cases := []struct {
|
|
key, id string
|
|
want int
|
|
}{
|
|
// ASCII baseline.
|
|
{"billing_new_invoice", "user-42", 97},
|
|
{"feature_a", "user-1", 50},
|
|
{"checkout_algo", "u-7f8a", 11},
|
|
{"ws_rollout", "workspace-1", 62},
|
|
{"empty_id_flag", "", 83},
|
|
// Non-ASCII: enforces UTF-8 parity with TextEncoder on the TS side.
|
|
{"flag", "é", 53},
|
|
{"flag", "🦄", 82},
|
|
{"实验", "user-1", 90},
|
|
{"flag", "用户-1", 95},
|
|
{"checkout_算法", "user-100", 79},
|
|
}
|
|
for _, tc := range cases {
|
|
got := bucketFor(tc.key, tc.id)
|
|
if got != tc.want {
|
|
t.Fatalf(
|
|
"cross-language golden mismatch: bucketFor(%q, %q) = %d, want %d. "+
|
|
"If you changed the hash you MUST also update hash.test.ts.",
|
|
tc.key, tc.id, got, tc.want,
|
|
)
|
|
}
|
|
}
|
|
}
|