//go:build integration package redisactivity import ( "context" "errors" "testing" "time" "proxy-pool/internal/domain/activitypool" healthDomain "proxy-pool/internal/domain/health" proxyDomain "proxy-pool/internal/domain/proxy" ) func TestRedisKeepsTargetHealthOutsideGlobalProxyState(t *testing.T) { fixture := newRedisTestFixture(t) now := redisTestNow() if _, err := fixture.Adapter.UpsertFetched(context.Background(), "provider-a", activitypool.FetchedBatch{ ObservedAt: now, ConfiguredTTL: time.Minute, MaxSize: 1, Proxies: []proxyDomain.Proxy{testProxy("proxy-a", "192.0.2.10")}, }); err != nil { t.Fatalf("UpsertFetched(): %v", err) } firstObservation := redisTargetObservation("target-task-1", false, now.Add(time.Second)) first, err := fixture.Adapter.ApplyTargetObservation(context.Background(), activitypool.TargetHealthCommand{ Observation: firstObservation, MaxConsecutiveFailures: 2, }) if err != nil || first.Status != healthDomain.TargetSuspect || first.ConsecutiveFailures != 1 || first.LastObservedAt != firstObservation.ObservedAt || first.Latency != firstObservation.Latency { t.Fatalf("ApplyTargetObservation(first) = %+v, %v", first, err) } replayed, err := fixture.Adapter.ApplyTargetObservation(context.Background(), activitypool.TargetHealthCommand{ Observation: firstObservation, MaxConsecutiveFailures: 2, }) if err != nil || replayed != first { t.Fatalf("ApplyTargetObservation(replay) = %+v, %v", replayed, err) } conflicting := firstObservation conflicting.Success = true conflicting.FailureClass = "" if _, err := fixture.Adapter.ApplyTargetObservation(context.Background(), activitypool.TargetHealthCommand{ Observation: conflicting, MaxConsecutiveFailures: 2, }); !errors.Is(err, healthDomain.ErrConflictingObservation) { t.Fatalf("ApplyTargetObservation(conflict) error = %v, want ErrConflictingObservation", err) } second, err := fixture.Adapter.ApplyTargetObservation(context.Background(), activitypool.TargetHealthCommand{ Observation: redisTargetObservation("target-task-2", false, now.Add(2*time.Second)), MaxConsecutiveFailures: 2, }) if err != nil || second.Status != healthDomain.TargetUnhealthy || second.ConsecutiveFailures != 2 { t.Fatalf("ApplyTargetObservation(second failure) = %+v, %v", second, err) } recoveredObservation := redisTargetObservation("target-task-3", true, now.Add(3*time.Second)) recovered, err := fixture.Adapter.ApplyTargetObservation(context.Background(), activitypool.TargetHealthCommand{ Observation: recoveredObservation, MaxConsecutiveFailures: 2, }) if err != nil || recovered.Status != healthDomain.TargetAvailable || recovered.ConsecutiveFailures != 0 || recovered.LastSuccessAt != recoveredObservation.ObservedAt { t.Fatalf("ApplyTargetObservation(recovery) = %+v, %v", recovered, err) } profile := healthDomain.TargetProfile{RoutingName: recoveredObservation.RoutingName, TargetURL: recoveredObservation.TargetURL} profileKey := fixture.Adapter.keys.targetHealth("proxy-a") field := digestToken(profile.Key()) if ttl, err := fixture.Client.PTTL(context.Background(), profileKey).Result(); err != nil || ttl <= 0 { t.Fatalf("target health key TTL = (%v, %v), want positive", ttl, err) } if exists, err := fixture.Client.HExists(context.Background(), profileKey, field).Result(); err != nil || !exists { t.Fatalf("target health profile exists = (%v, %v), want true", exists, err) } global, err := fixture.Adapter.ApplyHealth(context.Background(), activitypool.HealthUpdate{ ProxyID: "proxy-a", CheckedAt: now.Add(4 * time.Second), NextState: proxyDomain.StateChecking, }) if err != nil || global.State != proxyDomain.StateChecking || global.GlobalHealth.LastObservedAt != (time.Time{}) || global.GlobalHealth.ConsecutiveFailures != 0 { t.Fatalf("target observation changed global proxy state: %+v, %v", global, err) } } func redisTargetObservation(taskID string, success bool, observedAt time.Time) healthDomain.Observation { observation := healthDomain.Observation{ TaskID: taskID, ProxyID: "proxy-a", Level: healthDomain.LevelTarget, RoutingName: "route-a", TargetURL: "https://target.example/check", Success: success, Latency: 20 * time.Millisecond, ObservedAt: observedAt, } if !success { observation.FailureClass = "target_403" } return observation }