package transport import ( "context" "errors" "testing" "time" ) func TestIdempotencyStorePutGet(t *testing.T) { s := NewIdempotencyStore() if _, ok := s.Get("missing"); ok { t.Fatal("expected missing key to return ok=false") } s.Put("k1", "job-1") if jobID, ok := s.Get("k1"); !ok || jobID != "job-1" { t.Errorf("Get(k1): got (%q, %v), want (job-1, true)", jobID, ok) } } func TestIdempotencyStoreExpiry(t *testing.T) { s := NewIdempotencyStore() // Manually insert an expired entry. s.entries["expired"] = dedupeEntry{ key: "expired", jobID: "old-job", expiresAt: time.Now().Add(-1 * time.Minute), } if _, ok := s.Get("expired"); ok { t.Fatal("expected expired entry to return ok=false") } if _, exists := s.entries["expired"]; exists { t.Error("expected expired entry to be removed by Get") } } func TestIdempotencyStoreContext(t *testing.T) { ctx := WithIdempotencyKey(context.Background(), "key-1") if got := IdempotencyKeyFromContext(ctx); got != "key-1" { t.Errorf("IdempotencyKeyFromContext: got %q, want key-1", got) } ctx2 := context.Background() if got := IdempotencyKeyFromContext(ctx2); got != "" { t.Errorf("IdempotencyKeyFromContext(empty): got %q, want \"\"", got) } } func TestRetrySucceedsAfterTransient(t *testing.T) { calls := 0 got, err := Do(context.Background(), DefaultRetryPolicy(), func(_ context.Context, attempt int) (string, bool, error) { calls++ if attempt < 3 { return "", true, errors.New("connection refused: try again") } return "ok", true, nil }) if err != nil { t.Fatalf("Do: %v", err) } if got != "ok" { t.Errorf("Do: got %q, want ok", got) } if calls != 3 { t.Errorf("Do: got %d calls, want 3", calls) } } func TestRetryNoKeyOnTransient(t *testing.T) { // Without an idempotency key AND a non-idempotent verb, a // transient error on the first attempt must NOT retry (REQ-037). calls := 0 _, err := Do(context.Background(), DefaultRetryPolicy(), func(_ context.Context, _ int) (string, bool, error) { calls++ return "", false, errors.New("connection refused") }) if err == nil { t.Fatal("expected error, got nil") } if calls != 1 { t.Errorf("expected 1 call (no retry without key), got %d", calls) } } func TestRetryPermanentError(t *testing.T) { calls := 0 _, err := Do(context.Background(), DefaultRetryPolicy(), func(_ context.Context, _ int) (string, bool, error) { calls++ return "", true, ErrPermanent }) if !errors.Is(err, ErrPermanent) { t.Errorf("expected ErrPermanent, got %v", err) } if calls != 1 { t.Errorf("expected 1 call (permanent = no retry), got %d", calls) } } func TestRetryContextCancel(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() // cancel immediately calls := 0 _, err := Do(ctx, DefaultRetryPolicy(), func(_ context.Context, _ int) (string, bool, error) { calls++ return "", true, errors.New("EOF") }) if !errors.Is(err, context.Canceled) { t.Errorf("expected context.Canceled, got %v", err) } } func TestIdempotencyStoreSweep(t *testing.T) { s := NewIdempotencyStore() s.Put("live-1", "job-1") s.entries["expired"] = dedupeEntry{ key: "expired", jobID: "old-job", expiresAt: time.Now().Add(-1 * time.Minute), } s.Sweep() if _, ok := s.entries["expired"]; ok { t.Error("Sweep did not remove expired entry") } if _, ok := s.entries["live-1"]; !ok { t.Error("Sweep removed live entry") } } func TestIdempotencyStorePutEmpty(t *testing.T) { s := NewIdempotencyStore() s.Put("", "job-1") s.Put("k1", "") if _, ok := s.Get("k1"); ok { t.Error("Put with empty jobID should not store") } if _, ok := s.Get(""); ok { t.Error("Get with empty key should return false") } } func TestWithIdempotencyKeyEmpty(t *testing.T) { ctx := context.Background() got := WithIdempotencyKey(ctx, "") if got != ctx { t.Error("WithIdempotencyKey with empty key should return ctx unchanged") } } func TestIsTransient(t *testing.T) { cases := []struct { err error want bool }{ {nil, false}, {errors.New("connection refused"), true}, {errors.New("i/o timeout"), true}, {errors.New("EOF"), true}, {errors.New("no such host"), true}, {errors.New("connection reset by peer"), true}, {errors.New("invalid spec"), false}, } for _, c := range cases { if got := IsTransient(c.err); got != c.want { t.Errorf("IsTransient(%v): got %v, want %v", c.err, got, c.want) } } }