package inbound import ( "context" "errors" "testing" "time" "github.com/alicebob/miniredis/v2" ) func TestRedisRecoveryStoreBackoffAndStatuses(t *testing.T) { mr := miniredis.RunT(t) store, err := NewRedisRecoveryStore("redis://" + mr.Addr()) if err != nil { t.Fatalf("new recovery store: %v", err) } store.Prefix = "test:recovery" decision, err := store.StartAccountRecovery(context.Background(), "100001", "gateway-a") if err != nil { t.Fatalf("start recovery: %v", err) } if !decision.Allowed { t.Fatalf("expected recovery allowed, got %+v", decision) } if decision.Status.LockOwner != "gateway-a" || decision.Status.LockToken == "" || decision.Status.LockExpiresAt.IsZero() { t.Fatalf("missing recovery lock metadata: %+v", decision.Status) } status := decision.Status status.State = "waiting_connection" if err := store.CompleteAccountRecovery(context.Background(), status); err != nil { t.Fatalf("complete recovery: %v", err) } statuses, err := store.ListRecoveryStatuses(context.Background()) if err != nil { t.Fatalf("list recovery statuses: %v", err) } if len(statuses) != 1 || statuses[0].State != "waiting_connection" || statuses[0].AttemptCount != 1 { t.Fatalf("unexpected statuses: %+v", statuses) } if statuses[0].FailureCategory != "client_disconnected" { t.Fatalf("failure category = %q, want client_disconnected", statuses[0].FailureCategory) } if statuses[0].NextRetryAt.IsZero() { t.Fatalf("expected next retry at after waiting connection: %+v", statuses[0]) } decision, err = store.StartAccountRecovery(context.Background(), "100001", "gateway-a") if err != nil { t.Fatalf("start recovery second time: %v", err) } if decision.Allowed || decision.SkipReason != "backoff" { t.Fatalf("expected backoff skip, got %+v", decision) } if decision.Status.FailureCategory != "backoff" { t.Fatalf("skip failure category = %q, want backoff", decision.Status.FailureCategory) } } func TestRedisRecoveryStoreDoesNotReleaseLockOwnedByAnotherGateway(t *testing.T) { mr := miniredis.RunT(t) store, err := NewRedisRecoveryStore("redis://" + mr.Addr()) if err != nil { t.Fatalf("new recovery store: %v", err) } store.Prefix = "test:recovery:takeover" first, err := store.StartAccountRecovery(context.Background(), "100001", "gateway-a") if err != nil { t.Fatalf("start first recovery: %v", err) } if !first.Allowed { t.Fatalf("expected first recovery allowed, got %+v", first) } mr.FastForward(defaultRecoveryLockTTL + time.Second) second, err := store.StartAccountRecovery(context.Background(), "100001", "gateway-b") if err != nil { t.Fatalf("start second recovery: %v", err) } if !second.Allowed { t.Fatalf("expected second recovery to take over expired lock, got %+v", second) } stale := first.Status stale.State = "success" err = store.CompleteAccountRecovery(context.Background(), stale) if !errors.Is(err, ErrRecoveryLockLost) { t.Fatalf("stale completion error = %v, want ErrRecoveryLockLost", err) } lockValue, err := store.Client.Get(context.Background(), store.lockKey("100001")).Result() if err != nil { t.Fatalf("load current lock: %v", err) } if lockValue != second.Status.LockToken { t.Fatalf("current lock was changed by stale completion: got %q want %q", lockValue, second.Status.LockToken) } }