[
{"name": "no failures: every attempt remains", "args": [[], 1000, {"maxAttempts": 5, "windowSeconds": 900, "lockoutSeconds": 900}], "expect": {"allowed": true, "retryAfterSeconds": 0, "remainingAttempts": 5, "lockedUntil": null}},
{"name": "four failures in the window leave one attempt", "args": [[100, 200, 300, 400], 500, {"maxAttempts": 5, "windowSeconds": 900, "lockoutSeconds": 900}], "expect": {"allowed": true, "retryAfterSeconds": 0, "remainingAttempts": 1, "lockedUntil": null}},
{"name": "the fifth failure within 15 minutes locks the account for 15 minutes from that failure", "args": [[100, 200, 300, 400, 500], 600, {"maxAttempts": 5, "windowSeconds": 900, "lockoutSeconds": 900}], "expect": {"allowed": false, "retryAfterSeconds": 800, "remainingAttempts": 0, "lockedUntil": 1400}},
{"name": "one second before the lockout ends", "args": [[100, 200, 300, 400, 500], 1399, {"maxAttempts": 5, "windowSeconds": 900, "lockoutSeconds": 900}], "expect": {"allowed": false, "retryAfterSeconds": 1, "remainingAttempts": 0, "lockedUntil": 1400}},
{"name": "the lockout ends exactly at lockedUntil, with the full allowance back", "args": [[100, 200, 300, 400, 500], 1400, {"maxAttempts": 5, "windowSeconds": 900, "lockoutSeconds": 900}], "expect": {"allowed": true, "retryAfterSeconds": 0, "remainingAttempts": 5, "lockedUntil": null}},
{"name": "failures given out of order are replayed in time order", "args": [[500, 300, 100, 400, 200], 600, {"maxAttempts": 5, "windowSeconds": 900, "lockoutSeconds": 900}], "expect": {"allowed": false, "retryAfterSeconds": 800, "remainingAttempts": 0, "lockedUntil": 1400}},
{"name": "five failures spread over 20 minutes never have five in one window", "args": [[0, 300, 600, 900, 1200], 1300, {"maxAttempts": 5, "windowSeconds": 900, "lockoutSeconds": 900}], "expect": {"allowed": true, "retryAfterSeconds": 0, "remainingAttempts": 2, "lockedUntil": null}},
{"name": "failures during a lockout neither count nor extend it", "args": [[100, 200, 300, 400, 500, 600, 700], 800, {"maxAttempts": 5, "windowSeconds": 900, "lockoutSeconds": 900}], "expect": {"allowed": false, "retryAfterSeconds": 600, "remainingAttempts": 0, "lockedUntil": 1400}},
{"name": "after a lockout only newer failures count", "args": [[100, 200, 300, 400, 500, 1500], 1600, {"maxAttempts": 5, "windowSeconds": 900, "lockoutSeconds": 900}], "expect": {"allowed": true, "retryAfterSeconds": 0, "remainingAttempts": 4, "lockedUntil": null}},
{"name": "a second run of five failures locks again", "args": [[100, 200, 300, 400, 500, 1500, 1600, 1700, 1800, 1900], 2000, {"maxAttempts": 5, "windowSeconds": 900, "lockoutSeconds": 900}], "expect": {"allowed": false, "retryAfterSeconds": 800, "remainingAttempts": 0, "lockedUntil": 2800}},
{"name": "a failure stamped after now (clock skew) is ignored", "args": [[5000], 1000, {"maxAttempts": 5, "windowSeconds": 900, "lockoutSeconds": 900}], "expect": {"allowed": true, "retryAfterSeconds": 0, "remainingAttempts": 5, "lockedUntil": null}},
{"name": "old failures fall out of the window: at 1250 only the failure at 400 is within 15 minutes", "args": [[100, 200, 300, 400], 1250, {"maxAttempts": 5, "windowSeconds": 900, "lockoutSeconds": 900}], "expect": {"allowed": true, "retryAfterSeconds": 0, "remainingAttempts": 4, "lockedUntil": null}},
{"name": "one attempt allowed: locked by a single failure", "args": [[100], 120, {"maxAttempts": 1, "windowSeconds": 60, "lockoutSeconds": 30}], "expect": {"allowed": false, "retryAfterSeconds": 10, "remainingAttempts": 0, "lockedUntil": 130}},
{"name": "one attempt allowed: free again after the 30-second lockout", "args": [[100], 150, {"maxAttempts": 1, "windowSeconds": 60, "lockoutSeconds": 30}], "expect": {"allowed": true, "retryAfterSeconds": 0, "remainingAttempts": 1, "lockedUntil": null}},
{"name": "zero attempts is not a policy", "args": [[], 1000, {"maxAttempts": 0, "windowSeconds": 900, "lockoutSeconds": 900}], "expectError": "maxAttempts must be a whole number of at least 1"},
{"name": "a zero-second window", "args": [[], 1000, {"maxAttempts": 5, "windowSeconds": 0, "lockoutSeconds": 900}], "expectError": "windowSeconds must be a whole number of at least 1"},
{"name": "a zero-second lockout", "args": [[], 1000, {"maxAttempts": 5, "windowSeconds": 900, "lockoutSeconds": 0}], "expectError": "lockoutSeconds must be a whole number of at least 1"},
{"name": "failure times with fractions of a second", "args": [[100.5], 1000, {"maxAttempts": 5, "windowSeconds": 900, "lockoutSeconds": 900}], "expectError": "failures must be whole Unix seconds"},
{"name": "now in fractional seconds", "args": [[], 1000.5, {"maxAttempts": 5, "windowSeconds": 900, "lockoutSeconds": 900}], "expectError": "now must be a whole number of Unix seconds"}
]