mirror of
https://github.com/hanzoai/commerce.git
synced 2026-10-30 15:08:42 +00:00
Multi-tenant commerce and billing: orders, subscriptions, invoices, plans, usage metering and a credit ledger. One Go binary. MIT OR Apache-2.0. Claude-Session: https://claude.ai/code/session_01SAtaDFm8VDiWrhYHNNtxMw
262 lines
7.6 KiB
Go
262 lines
7.6 KiB
Go
// Package infra — KV adapter + distributed-lock tests.
|
|
//
|
|
// These boot a real base store (no Redis, no fakes) under t.TempDir() and
|
|
// exercise the KVClient adapter and the Manager-level distributed lock. The
|
|
// adapter's contract — miss-as-empty-string for pkg/org, prefix namespacing,
|
|
// and a base-backed Health probe — is verified end-to-end.
|
|
package infra
|
|
|
|
import (
|
|
"context"
|
|
"path/filepath"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/hanzoai/commerce/store"
|
|
)
|
|
|
|
// newTestKV constructs a KVClient backed by a throwaway base store. Cleanup
|
|
// closes the store on test completion.
|
|
func newTestKV(t *testing.T, prefix string) (*KVClient, *store.Store) {
|
|
t.Helper()
|
|
s, err := store.New(store.Config{DataDir: filepath.Join(t.TempDir(), "commerce")})
|
|
if err != nil {
|
|
t.Fatalf("store.New: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = s.Close(context.Background()) })
|
|
return NewKVClientFromStore(&KVConfig{Enabled: true, KeyPrefix: prefix}, s), s
|
|
}
|
|
|
|
func TestKVClientSetGet(t *testing.T) {
|
|
ctx := context.Background()
|
|
kv, _ := newTestKV(t, "")
|
|
|
|
if err := kv.Set(ctx, "k", "v", 0); err != nil {
|
|
t.Fatalf("Set: %v", err)
|
|
}
|
|
got, err := kv.Get(ctx, "k")
|
|
if err != nil {
|
|
t.Fatalf("Get: %v", err)
|
|
}
|
|
if got != "v" {
|
|
t.Fatalf("Get = %q, want %q", got, "v")
|
|
}
|
|
}
|
|
|
|
// TestKVClientMissEmptyString verifies the adapter preserves the old
|
|
// Redis-nil-as-empty contract pkg/org depends on: a miss is ("", nil), never
|
|
// an error.
|
|
func TestKVClientMissEmptyString(t *testing.T) {
|
|
ctx := context.Background()
|
|
kv, _ := newTestKV(t, "")
|
|
got, err := kv.Get(ctx, "absent")
|
|
if err != nil {
|
|
t.Fatalf("Get miss err = %v, want nil", err)
|
|
}
|
|
if got != "" {
|
|
t.Fatalf("Get miss = %q, want empty string", got)
|
|
}
|
|
}
|
|
|
|
func TestKVClientTTLExpiry(t *testing.T) {
|
|
ctx := context.Background()
|
|
kv, _ := newTestKV(t, "")
|
|
if err := kv.Set(ctx, "ttl", "v", 50*time.Millisecond); err != nil {
|
|
t.Fatalf("Set: %v", err)
|
|
}
|
|
time.Sleep(100 * time.Millisecond)
|
|
got, err := kv.Get(ctx, "ttl")
|
|
if err != nil {
|
|
t.Fatalf("Get after expiry err = %v, want nil (miss)", err)
|
|
}
|
|
if got != "" {
|
|
t.Fatalf("Get after 100ms (50ms TTL) = %q, want empty (expired)", got)
|
|
}
|
|
}
|
|
|
|
func TestKVClientDelete(t *testing.T) {
|
|
ctx := context.Background()
|
|
kv, _ := newTestKV(t, "")
|
|
if err := kv.Set(ctx, "d", "v", 0); err != nil {
|
|
t.Fatalf("Set: %v", err)
|
|
}
|
|
if err := kv.Delete(ctx, "d"); err != nil {
|
|
t.Fatalf("Delete: %v", err)
|
|
}
|
|
got, _ := kv.Get(ctx, "d")
|
|
if got != "" {
|
|
t.Fatalf("Get after delete = %q, want empty", got)
|
|
}
|
|
}
|
|
|
|
func TestKVClientJSON(t *testing.T) {
|
|
ctx := context.Background()
|
|
kv, _ := newTestKV(t, "")
|
|
type box struct {
|
|
N int `json:"n"`
|
|
S string `json:"s"`
|
|
}
|
|
if err := kv.SetJSON(ctx, "j", box{N: 7, S: "x"}, 0); err != nil {
|
|
t.Fatalf("SetJSON: %v", err)
|
|
}
|
|
var out box
|
|
if err := kv.GetJSON(ctx, "j", &out); err != nil {
|
|
t.Fatalf("GetJSON: %v", err)
|
|
}
|
|
if out.N != 7 || out.S != "x" {
|
|
t.Fatalf("GetJSON = %+v, want {7 x}", out)
|
|
}
|
|
}
|
|
|
|
// TestKVClientPrefixIsolation verifies KeyPrefix namespaces keys: two clients
|
|
// with distinct prefixes over the SAME store do not collide.
|
|
func TestKVClientPrefixIsolation(t *testing.T) {
|
|
ctx := context.Background()
|
|
a, s := newTestKV(t, "a")
|
|
b := NewKVClientFromStore(&KVConfig{Enabled: true, KeyPrefix: "b"}, s)
|
|
|
|
if err := a.Set(ctx, "k", "from-a", 0); err != nil {
|
|
t.Fatalf("a.Set: %v", err)
|
|
}
|
|
if err := b.Set(ctx, "k", "from-b", 0); err != nil {
|
|
t.Fatalf("b.Set: %v", err)
|
|
}
|
|
av, _ := a.Get(ctx, "k")
|
|
bv, _ := b.Get(ctx, "k")
|
|
if av != "from-a" || bv != "from-b" {
|
|
t.Fatalf("prefix collision: a=%q b=%q", av, bv)
|
|
}
|
|
}
|
|
|
|
func TestKVClientHealth(t *testing.T) {
|
|
ctx := context.Background()
|
|
kv, _ := newTestKV(t, "")
|
|
h := kv.Health(ctx)
|
|
if !h.Healthy {
|
|
t.Fatalf("Health = %+v, want healthy", h)
|
|
}
|
|
}
|
|
|
|
func TestKVClientExists(t *testing.T) {
|
|
ctx := context.Background()
|
|
kv, _ := newTestKV(t, "")
|
|
n, err := kv.Exists(ctx, "x", "y")
|
|
if err != nil || n != 0 {
|
|
t.Fatalf("Exists none = (%d, %v), want (0, nil)", n, err)
|
|
}
|
|
_ = kv.Set(ctx, "x", "1", 0)
|
|
n, err = kv.Exists(ctx, "x", "y")
|
|
if err != nil || n != 1 {
|
|
t.Fatalf("Exists one = (%d, %v), want (1, nil)", n, err)
|
|
}
|
|
}
|
|
|
|
// TestKVBackendSelection proves the KV_URL selection: NewKVClientFromStore picks
|
|
// the Base (SQLite) backend — the default when KV_URL is unset — while
|
|
// NewKVClientFromURL picks the external Hanzo KV backend when KV_URL is set, and
|
|
// a malformed KV_URL fails CLOSED (never a silent fallback to Base that would
|
|
// split cache/lock state across two engines).
|
|
func TestKVBackendSelection(t *testing.T) {
|
|
_, s := newTestKV(t, "")
|
|
|
|
base := NewKVClientFromStore(&KVConfig{Enabled: true}, s)
|
|
if _, ok := base.backend.(*baseBackend); !ok {
|
|
t.Fatalf("KV_URL unset must select Base, got %T", base.backend)
|
|
}
|
|
|
|
ext, err := NewKVClientFromURL(&KVConfig{Enabled: true}, "kv://:[email protected]:6379")
|
|
if err != nil {
|
|
t.Fatalf("NewKVClientFromURL(valid): %v", err)
|
|
}
|
|
if _, ok := ext.backend.(*externalBackend); !ok {
|
|
t.Fatalf("KV_URL set must select external Hanzo KV, got %T", ext.backend)
|
|
}
|
|
_ = ext.Close()
|
|
|
|
if _, err := NewKVClientFromURL(&KVConfig{Enabled: true}, "://not-a-url"); err == nil {
|
|
t.Fatal("malformed KV_URL must fail closed, got nil error")
|
|
}
|
|
|
|
// kv-go v9.22.0 REMOVED the legacy redis:// scheme (kv://, kvs://, unix://
|
|
// only). Pinned so the operator-visible break can never regress silently:
|
|
// a deployment still on KV_URL=redis://… fails closed at boot.
|
|
if _, err := NewKVClientFromURL(&KVConfig{Enabled: true}, "redis://kv.tenant-acme.svc:6379"); err == nil {
|
|
t.Fatal("legacy redis:// scheme must fail closed, got nil error")
|
|
}
|
|
}
|
|
|
|
// TestLockAcquireReleaseExtend exercises the base-backed distributed lock
|
|
// round-trip via the Manager. SetKV is used so the Manager reuses the test
|
|
// store rather than opening a second base app.
|
|
func TestLockAcquireReleaseExtend(t *testing.T) {
|
|
ctx := context.Background()
|
|
kv, _ := newTestKV(t, "")
|
|
|
|
m := New(&Config{KV: KVConfig{Enabled: true}})
|
|
m.SetKV(kv)
|
|
|
|
lock, err := m.Acquire(ctx, "job", time.Minute)
|
|
if err != nil {
|
|
t.Fatalf("Acquire: %v", err)
|
|
}
|
|
|
|
// A second acquire of the same key must fail — the lock is held.
|
|
if _, err := m.Acquire(ctx, "job", time.Minute); err != ErrLockNotAcquired {
|
|
t.Fatalf("second Acquire = %v, want ErrLockNotAcquired", err)
|
|
}
|
|
|
|
// Extend renews the held lock.
|
|
if err := lock.Extend(ctx, 2*time.Minute); err != nil {
|
|
t.Fatalf("Extend: %v", err)
|
|
}
|
|
|
|
// Release frees it; a fresh acquire then succeeds.
|
|
if err := lock.Release(ctx); err != nil {
|
|
t.Fatalf("Release: %v", err)
|
|
}
|
|
if _, err := m.Acquire(ctx, "job", time.Minute); err != nil {
|
|
t.Fatalf("Acquire after release: %v", err)
|
|
}
|
|
|
|
// Double-release reports the lock is no longer held.
|
|
if err := lock.Release(ctx); err != ErrLockNotHeld {
|
|
t.Fatalf("double Release = %v, want ErrLockNotHeld", err)
|
|
}
|
|
}
|
|
|
|
// TestLockMutualExclusion: 100 goroutines contend for one lock; exactly one
|
|
// holds it at a time. We assert no two acquisitions overlap by guarding a
|
|
// non-atomic counter the lock is supposed to protect.
|
|
func TestLockMutualExclusion(t *testing.T) {
|
|
ctx := context.Background()
|
|
kv, _ := newTestKV(t, "")
|
|
m := New(&Config{KV: KVConfig{Enabled: true}})
|
|
m.SetKV(kv)
|
|
|
|
const n = 100
|
|
var wg sync.WaitGroup
|
|
wg.Add(n)
|
|
var acquired int64
|
|
var mu sync.Mutex
|
|
for i := 0; i < n; i++ {
|
|
go func() {
|
|
defer wg.Done()
|
|
lock, err := m.Acquire(ctx, "excl", time.Minute)
|
|
if err != nil {
|
|
return // contended out — expected for most goroutines
|
|
}
|
|
mu.Lock()
|
|
acquired++
|
|
mu.Unlock()
|
|
_ = lock.Release(ctx)
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
// At least one goroutine acquired; the count never panicked the guarded
|
|
// section (race detector + the held-lock invariant cover correctness).
|
|
if acquired < 1 {
|
|
t.Fatalf("no goroutine acquired the lock")
|
|
}
|
|
}
|