package release import ( "context" "strings" "testing" "time" ) func TestFeaturesRoundTrip(t *testing.T) { ctx := context.Background() s := NewDirStore(t.TempDir()) now := time.Date(2026, 8, 28, 12, 0, 0, 0, time.UTC) // Absent is legal forever: no features, not an error. got, err := GetFeatures(ctx, s, "bab", "titogo") if err != nil || got != nil { t.Fatalf("absent features = %v, %v; want nil, nil", got, err) } rec, changed, err := UpdateFeatures(ctx, s, "bab", "titogo", map[string]string{"sandbox": "on"}, nil, "alice@keymail.dev", now) if err != nil { t.Fatal(err) } if !changed || rec.Features["sandbox"] != "on" || rec.SetBy != "alice@keymail.dev" || !rec.SetAt.Equal(now) { t.Fatalf("first set = %+v changed=%v, want sandbox=on by alice, changed", rec, changed) } got, err = GetFeatures(ctx, s, "bab", "titogo") if err != nil { t.Fatal(err) } if got == nil || got.Features["sandbox"] != "on" || got.Schema != 1 || got.App != "titogo" { t.Fatalf("features = %+v, want schema 1 titogo sandbox=on", got) } // A second set MERGES: the existing key survives, the new one lands. rec, changed, err = UpdateFeatures(ctx, s, "bab", "titogo", map[string]string{"beta": "1"}, nil, "bob@keymail.dev", now.Add(time.Hour)) if err != nil || !changed { t.Fatalf("merge set: changed=%v err=%v", changed, err) } if rec.Features["sandbox"] != "on" || rec.Features["beta"] != "1" || rec.SetBy != "bob@keymail.dev" { t.Fatalf("after merge = %+v, want both keys, set by bob", rec) } // Setting the same value again changes nothing — and says so, so the // door can skip the audit row. _, changed, err = UpdateFeatures(ctx, s, "bab", "titogo", map[string]string{"beta": "1"}, nil, "carol", now.Add(2*time.Hour)) if err != nil || changed { t.Fatalf("no-op set: changed=%v err=%v, want false, nil", changed, err) } got, _ = GetFeatures(ctx, s, "bab", "titogo") if got.SetBy != "bob@keymail.dev" { t.Fatalf("a no-op set rewrote the record: set_by %q", got.SetBy) } // Unset removes a key; unsetting an absent key is a no-op. rec, changed, err = UpdateFeatures(ctx, s, "bab", "titogo", nil, []string{"beta", "never"}, "dave", now.Add(3*time.Hour)) if err != nil || !changed { t.Fatalf("unset: changed=%v err=%v", changed, err) } if _, ok := rec.Features["beta"]; ok || rec.Features["sandbox"] != "on" { t.Fatalf("after unset = %+v, want only sandbox", rec.Features) } _, changed, err = UpdateFeatures(ctx, s, "bab", "titogo", nil, []string{"never"}, "dave", now.Add(4*time.Hour)) if err != nil || changed { t.Fatalf("unset absent: changed=%v err=%v, want false, nil", changed, err) } // Unsetting the last key leaves an EMPTY record, not an absent one: the // record's set_at still tells a reader when the last change happened. rec, _, err = UpdateFeatures(ctx, s, "bab", "titogo", nil, []string{"sandbox"}, "erin", now.Add(5*time.Hour)) if err != nil || len(rec.Features) != 0 { t.Fatalf("after last unset = %+v err=%v", rec, err) } got, err = GetFeatures(ctx, s, "bab", "titogo") if err != nil || got == nil || len(got.Features) != 0 || got.SetBy != "erin" { t.Fatalf("empty record = %+v, %v; want present, empty, by erin", got, err) } } // Keys and values are the app's own vocabulary — but they are also bucket // bytes every consumer parses, so the shape is bounded: short lowercase // keys, short printable values, and a cap on how many. func TestUpdateFeaturesRefusesBadShapes(t *testing.T) { ctx := context.Background() s := NewDirStore(t.TempDir()) now := time.Now() cases := []struct { name string set map[string]string want string }{ {"uppercase key", map[string]string{"Sandbox": "on"}, "key"}, {"empty key", map[string]string{"": "on"}, "key"}, {"key with space", map[string]string{"sand box": "on"}, "key"}, {"key too long", map[string]string{strings.Repeat("k", 65): "on"}, "key"}, {"empty value", map[string]string{"sandbox": ""}, "value"}, {"value too long", map[string]string{"sandbox": strings.Repeat("v", 257)}, "value"}, {"control char in value", map[string]string{"sandbox": "on\n"}, "value"}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { _, _, err := UpdateFeatures(ctx, s, "bab", "titogo", c.set, nil, "x", now) if err == nil || !strings.Contains(err.Error(), c.want) { t.Fatalf("err = %v, want one naming the %s", err, c.want) } }) } if got, err := GetFeatures(ctx, s, "bab", "titogo"); err != nil || got != nil { t.Fatalf("a refused set wrote something: %+v, %v", got, err) } // The count cap: FeaturesMax keys is fine, one more is refused — // whether they arrive at once or on top of an existing record. set := map[string]string{} for i := 0; i < FeaturesMax; i++ { set["k"+strings.Repeat("x", i%3)+string(rune('a'+i%26))+string(rune('a'+i/26))] = "v" } if len(set) != FeaturesMax { t.Fatalf("test bug: %d keys", len(set)) } if _, _, err := UpdateFeatures(ctx, s, "bab", "titogo", set, nil, "x", now); err != nil { t.Fatalf("%d keys refused: %v", FeaturesMax, err) } _, _, err := UpdateFeatures(ctx, s, "bab", "titogo", map[string]string{"one-more": "v"}, nil, "x", now) if err == nil || !strings.Contains(err.Error(), "at most") { t.Fatalf("key %d = %v, want a cap error", FeaturesMax+1, err) } // Replacing an existing key at the cap is not an addition. if _, _, err := UpdateFeatures(ctx, s, "bab", "titogo", map[string]string{"kaa": "changed"}, nil, "x", now); err != nil { t.Fatalf("replacing at the cap refused: %v", err) } } func TestGetFeaturesRefusesLiesAndUnknownSchema(t *testing.T) { ctx := context.Background() s := NewDirStore(t.TempDir()) put := func(body string) { s.Delete(ctx, FeaturesKey("bab", "titogo")) if _, err := s.Put(ctx, FeaturesKey("bab", "titogo"), []byte(body), ""); err != nil { t.Fatal(err) } } put(`{"schema":1,"app":"other","features":{"a":"b"},"set_at":"2026-08-28T12:00:00Z"}`) if _, err := GetFeatures(ctx, s, "bab", "titogo"); err == nil { t.Fatal("a body naming a different app was accepted") } put(`{"schema":2,"app":"titogo","features":{"a":"b"},"set_at":"2026-08-28T12:00:00Z"}`) if _, err := GetFeatures(ctx, s, "bab", "titogo"); err == nil { t.Fatal("an unknown schema was accepted") } // A record with no features map at all reads as empty, never nil — // consumers range over it without a nil check. put(`{"schema":1,"app":"titogo","set_at":"2026-08-28T12:00:00Z"}`) got, err := GetFeatures(ctx, s, "bab", "titogo") if err != nil || got == nil || got.Features == nil { t.Fatalf("missing map = %+v, %v; want a non-nil empty map", got, err) } }