|
6 | 6 | "testing" |
7 | 7 | "time" |
8 | 8 |
|
| 9 | + "github.com/hivecommons/hive/pkg/config" |
9 | 10 | "github.com/hivecommons/hive/pkg/governor" |
10 | 11 | standbypkg "github.com/hivecommons/hive/pkg/standby" |
11 | 12 | ) |
@@ -214,3 +215,142 @@ func TestRoleCoverage_RejectedStandbyDeclarationDoesNotCover(t *testing.T) { |
214 | 215 | t.Fatalf("rejected standby relay counted as cover: RoleCoverage = %+v, want nil", got) |
215 | 216 | } |
216 | 217 | } |
| 218 | + |
| 219 | +// rateLimitedCoverageHub builds a hub whose "contributor" tier has the given |
| 220 | +// limits and seeds identityOf(relay) with one assignment stamp per age, as |
| 221 | +// recordAssignment does when selection hands a task out. |
| 222 | +func rateLimitedCoverageHub(t *testing.T, limits config.TierRate, relay *ContributorConnection, now time.Time, ages ...time.Duration) (*ContributeWSHub, *Server) { |
| 223 | + t.Helper() |
| 224 | + hub, s := covK2Hub(t) |
| 225 | + s.deps.Config.Hub.TierLimits = map[string]config.TierRate{"contributor": limits} |
| 226 | + for _, age := range ages { |
| 227 | + hub.recordAssignment(identityOf(relay), now.Add(-age)) |
| 228 | + } |
| 229 | + hub.mu.Lock() |
| 230 | + hub.connections[relay.profile.GitHubUsername] = relay |
| 231 | + hub.mu.Unlock() |
| 232 | + return hub, s |
| 233 | +} |
| 234 | + |
| 235 | +// selectionRefusal asks real task selection for work on behalf of relay's |
| 236 | +// identity and returns the task_unavailable reason. The probe connection has |
| 237 | +// no role, so the answer is about the identity's limits, not role policy. |
| 238 | +func selectionRefusal(t *testing.T, hub *ContributeWSHub, s *Server, relay *ContributorConnection) string { |
| 239 | + t.Helper() |
| 240 | + oneActionableIssueN(s, 0) |
| 241 | + msg := hub.selectTask(&ContributorConnection{profile: relay.profile, lastPong: time.Now()}) |
| 242 | + if msg == nil || msg.Type != "task_unavailable" { |
| 243 | + t.Fatalf("setup: selection must refuse this identity, got %+v", msg) |
| 244 | + } |
| 245 | + return msg.Reason |
| 246 | +} |
| 247 | + |
| 248 | +// A relay that has been handed its tier's max_per_hour tasks in the last hour |
| 249 | +// is refused work with hourly_limit until the window rolls. It is connected |
| 250 | +// and acting as the role, but it will not take the lane's next item, so it |
| 251 | +// must not keep the resident agent withheld. |
| 252 | +func TestRoleCoverage_HourlyLimitReachedDoesNotCover(t *testing.T) { |
| 253 | + now := time.Now() |
| 254 | + relay := coverageConn("alice", "quality", now) |
| 255 | + hub, s := rateLimitedCoverageHub(t, config.TierRate{MaxPerHour: 2, MaxPerDay: 50, MaxConcurrent: 5}, |
| 256 | + relay, now, 10*time.Minute, 20*time.Minute) |
| 257 | + |
| 258 | + if got := selectionRefusal(t, hub, s, relay); got != taskUnavailableHourlyLimit { |
| 259 | + t.Fatalf("setup: selection refused with %q, want %q", got, taskUnavailableHourlyLimit) |
| 260 | + } |
| 261 | + if got := hub.RoleCoverage(now); got != nil { |
| 262 | + t.Fatalf("relay at its hourly limit counted as cover: RoleCoverage = %+v, want nil", got) |
| 263 | + } |
| 264 | +} |
| 265 | + |
| 266 | +// The same for the daily cap: no assignment in the last hour, but the tier's |
| 267 | +// max_per_day in the last 24 hours, so selection refuses with daily_limit. |
| 268 | +func TestRoleCoverage_DailyLimitReachedDoesNotCover(t *testing.T) { |
| 269 | + now := time.Now() |
| 270 | + relay := coverageConn("alice", "scanner", now) |
| 271 | + hub, s := rateLimitedCoverageHub(t, config.TierRate{MaxPerHour: 10, MaxPerDay: 3, MaxConcurrent: 5}, |
| 272 | + relay, now, 2*time.Hour, 5*time.Hour, 20*time.Hour) |
| 273 | + |
| 274 | + if got := selectionRefusal(t, hub, s, relay); got != taskUnavailableDailyLimit { |
| 275 | + t.Fatalf("setup: selection refused with %q, want %q", got, taskUnavailableDailyLimit) |
| 276 | + } |
| 277 | + if got := hub.RoleCoverage(now); got != nil { |
| 278 | + t.Fatalf("relay at its daily limit counted as cover: RoleCoverage = %+v, want nil", got) |
| 279 | + } |
| 280 | +} |
| 281 | + |
| 282 | +// A relay one task short of both caps still covers: only a used-up allowance |
| 283 | +// takes a relay out. Stamps older than the day window do not count. Bob, in |
| 284 | +// the same tier and lane, is at the hourly cap and is the only one dropped. |
| 285 | +func TestRoleCoverage_UnderRateLimitsCovers(t *testing.T) { |
| 286 | + now := time.Now() |
| 287 | + alice := coverageConn("alice", "quality", now) |
| 288 | + bob := coverageConn("bob", "quality", now) |
| 289 | + // alice: 1 of 2 this hour, 2 of 3 today (the 25h-old stamp has rolled off). |
| 290 | + hub, _ := rateLimitedCoverageHub(t, config.TierRate{MaxPerHour: 2, MaxPerDay: 3, MaxConcurrent: 5}, |
| 291 | + alice, now, 10*time.Minute, 3*time.Hour, 25*time.Hour) |
| 292 | + hub.recordAssignment(identityOf(bob), now.Add(-5*time.Minute)) |
| 293 | + hub.recordAssignment(identityOf(bob), now.Add(-15*time.Minute)) |
| 294 | + hub.mu.Lock() |
| 295 | + hub.connections["bob"] = bob |
| 296 | + hub.mu.Unlock() |
| 297 | + |
| 298 | + want := map[string]governor.ContributorCoverage{"quality": {Contributors: []string{"alice"}}} |
| 299 | + if got := hub.RoleCoverage(now); !reflect.DeepEqual(got, want) { |
| 300 | + t.Fatalf("RoleCoverage = %+v, want %+v (alice under both caps, bob at the hourly cap)", got, want) |
| 301 | + } |
| 302 | +} |
| 303 | + |
| 304 | +// tierRateLimitReached is the single rule selection and RoleCoverage share. |
| 305 | +func TestTierRateLimitReached(t *testing.T) { |
| 306 | + now := time.Date(2026, 11, 8, 12, 0, 0, 0, time.UTC) |
| 307 | + cases := []struct { |
| 308 | + name string |
| 309 | + limits map[string]config.TierRate |
| 310 | + tier string |
| 311 | + ages []time.Duration |
| 312 | + want tierRateLimitHit |
| 313 | + }{ |
| 314 | + {name: "under both caps", limits: map[string]config.TierRate{"contributor": {MaxPerHour: 2, MaxPerDay: 3}}, |
| 315 | + tier: "contributor", ages: []time.Duration{time.Minute, 2 * time.Hour}}, |
| 316 | + {name: "at the hourly cap", limits: map[string]config.TierRate{"contributor": {MaxPerHour: 2, MaxPerDay: 50}}, |
| 317 | + tier: "contributor", ages: []time.Duration{time.Minute, 30 * time.Minute}, |
| 318 | + want: tierRateLimitHit{Reason: taskUnavailableHourlyLimit, Count: 2, Max: 2}}, |
| 319 | + {name: "at the daily cap", limits: map[string]config.TierRate{"contributor": {MaxPerHour: 10, MaxPerDay: 2}}, |
| 320 | + tier: "contributor", ages: []time.Duration{2 * time.Hour, 23 * time.Hour}, |
| 321 | + want: tierRateLimitHit{Reason: taskUnavailableDailyLimit, Count: 2, Max: 2}}, |
| 322 | + {name: "both caps reports hourly first", limits: map[string]config.TierRate{"contributor": {MaxPerHour: 1, MaxPerDay: 1}}, |
| 323 | + tier: "contributor", ages: []time.Duration{time.Minute}, |
| 324 | + want: tierRateLimitHit{Reason: taskUnavailableHourlyLimit, Count: 1, Max: 1}}, |
| 325 | + {name: "stamps past the day window do not count", limits: map[string]config.TierRate{"contributor": {MaxPerHour: 1, MaxPerDay: 1}}, |
| 326 | + tier: "contributor", ages: []time.Duration{25 * time.Hour}}, |
| 327 | + {name: "zero caps are unlimited", limits: map[string]config.TierRate{"advisor": {}}, |
| 328 | + tier: "advisor", ages: []time.Duration{time.Minute, 2 * time.Minute, 3 * time.Minute}}, |
| 329 | + {name: "a tier with no row is unlimited", limits: map[string]config.TierRate{"contributor": {MaxPerHour: 1, MaxPerDay: 1}}, |
| 330 | + tier: "custom", ages: []time.Duration{time.Minute, 2 * time.Minute}}, |
| 331 | + } |
| 332 | + for _, tc := range cases { |
| 333 | + t.Run(tc.name, func(t *testing.T) { |
| 334 | + hub, s := covK2Hub(t) |
| 335 | + s.deps.Config.Hub.TierLimits = tc.limits |
| 336 | + for _, age := range tc.ages { |
| 337 | + hub.recordAssignment("c-alice", now.Add(-age)) |
| 338 | + } |
| 339 | + if got := hub.tierRateLimitReached("c-alice", tc.tier, now); got != tc.want { |
| 340 | + t.Fatalf("tierRateLimitReached = %+v, want %+v", got, tc.want) |
| 341 | + } |
| 342 | + }) |
| 343 | + } |
| 344 | + |
| 345 | + // No server or config behind the hub: nothing to enforce, never a panic. |
| 346 | + bare := NewContributeWSHub(slog.Default(), nil) |
| 347 | + t.Cleanup(bare.Close) |
| 348 | + bare.recordAssignment("c-alice", now) |
| 349 | + if got := bare.tierRateLimitReached("c-alice", "contributor", now); got != (tierRateLimitHit{}) { |
| 350 | + t.Fatalf("hub without a server: tierRateLimitReached = %+v, want zero", got) |
| 351 | + } |
| 352 | + var none *ContributeWSHub |
| 353 | + if got := none.tierRateLimitReached("c-alice", "contributor", now); got != (tierRateLimitHit{}) { |
| 354 | + t.Fatalf("nil hub: tierRateLimitReached = %+v, want zero", got) |
| 355 | + } |
| 356 | +} |
0 commit comments