package main import ( "encoding/json" "net/http" "testing" ) func TestParseReplicationClasses(t *testing.T) { got, err := parseReplicationClasses("free=daily,family=daily,community=batched,business=stream") if err != nil { t.Fatal(err) } want := map[string]string{"free": "daily", "family": "daily", "community": "batched", "business": "stream"} if len(got) != len(want) { t.Fatalf("parsed %v, want %v", got, want) } for k, v := range want { if got[k] != v { t.Errorf("classes[%q] = %q, want %q", k, got[k], v) } } // Unset = feature off, not an error. if m, err := parseReplicationClasses(""); err != nil || m != nil { t.Errorf("empty env = (%v, %v), want (nil, nil)", m, err) } // A class the router doesn't know must fail at startup, not silently // stream forever on the box. if _, err := parseReplicationClasses("free=hourly"); err == nil { t.Error("unknown class accepted") } if _, err := parseReplicationClasses("free"); err == nil { t.Error("entry without = accepted") } // An EMPTY class value is rejected too — "" is valid to validReplication // (it's the unset registry default) but meaningless as config. if _, err := parseReplicationClasses("free="); err == nil { t.Error("empty class value accepted") } } // replicationFor is the policy: configured keys act, free without a "free" // entry pushes nothing, and an unknown PAID tier fails toward durability // (stream) — a config typo must never quietly put a paying space on the // nightly rung. func TestReplicationFor(t *testing.T) { s := &cloudServer{replicationClasses: map[string]string{ "free": "daily", "family": "daily", "community": "batched", "business": "stream", }} for _, tc := range []struct { ent, tier, want string }{ {entFree, "", "daily"}, {entFree, "family", "daily"}, // stale tier on a lapsed row: entitlement wins {entPaid, "family", "daily"}, {entPaid, "community", "batched"}, {entPaid, "business", "stream"}, {entPaid, "buisness", "stream"}, // typo'd/unknown paid tier → stream, never a downgrade } { if got := s.replicationFor(tc.ent, tc.tier); got != tc.want { t.Errorf("replicationFor(%q, %q) = %q, want %q", tc.ent, tc.tier, got, tc.want) } } // Unconfigured = push nothing anywhere. off := &cloudServer{} if got := off.replicationFor(entPaid, "business"); got != "" { t.Errorf("unconfigured replicationFor = %q, want \"\"", got) } // Configured but no "free" key: free spaces keep whatever they have. partial := &cloudServer{replicationClasses: map[string]string{"business": "stream"}} if got := partial.replicationFor(entFree, ""); got != "" { t.Errorf("free with no free entry = %q, want \"\"", got) } } // replicationControl fakes controld's stats + replication endpoints, recording // class pushes (the stats half keeps meterOnce's sweep loop running). func replicationControl(t *testing.T, pushed map[string]string) string { t.Helper() mux := http.NewServeMux() mux.HandleFunc("GET /instances/{host}/stats", func(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"storage_bytes": int64(1)}) }) mux.HandleFunc("POST /instances/{host}/replication", func(w http.ResponseWriter, r *http.Request) { var req struct { Class string `json:"class"` } json.NewDecoder(r.Body).Decode(&req) pushed[r.PathValue("host")] = req.Class writeJSON(w, http.StatusOK, map[string]any{"ok": true, "changed": true}) }) return fakeControl(t, mux.ServeHTTP) } // The class enforcement loop, mirroring TestQuotaPushOnUpgradeAndSweep: a // checkout upgrade pushes the tier's class immediately, the meter sweep // reconciles everyone, and a lapse drops the space back to the free rung. func TestReplicationPushOnUpgradeAndSweep(t *testing.T) { db := openTestCloudDB(t) paidID := insertSpaceRow(t, db, "paid.11ch.at", 1000) insertSpaceRow(t, db, "free.11ch.at", 1000) pushed := map[string]string{} sock := replicationControl(t, pushed) s := &cloudServer{ db: db, control: controlClient(sock), controlToken: "t", replicationClasses: map[string]string{"free": "daily", "business": "stream"}, } // The upgrade path (what the checkout webhook calls). if err := db.markSpacePaid(paidID, "cus", "sub", "business", ""); err != nil { t.Fatal(err) } s.pushReplicationByID(paidID) if pushed["paid.11ch.at"] != "stream" { t.Fatalf("upgrade push = %q, want stream", pushed["paid.11ch.at"]) } // The sweep reconciles both (controld's changed=false makes re-pushes // harmless, so unlike quota there is no pushed-state comparison here). s.meterOnce() if pushed["free.11ch.at"] != "daily" { t.Fatalf("sweep push = %q, want daily", pushed["free.11ch.at"]) } // A lapse (subscription deleted → free) re-applies the free rung. if err := db.setEntitlement(paidID, entFree, 0); err != nil { t.Fatal(err) } s.pushReplicationByID(paidID) if pushed["paid.11ch.at"] != "daily" { t.Fatalf("lapse push = %q, want back to daily", pushed["paid.11ch.at"]) } // No classes configured → nothing pushes anywhere. pushed2 := map[string]string{} sock2 := replicationControl(t, pushed2) s2 := &cloudServer{db: db, control: controlClient(sock2), controlToken: "t"} s2.meterOnce() s2.pushReplicationByID(paidID) if len(pushed2) != 0 { t.Errorf("unconfigured classes pushed: %v", pushed2) } }