$this->store = app(BadIpStore::class)); afterEach(fn () => CarbonImmutable::setTestNow()); it('starts empty when no file exists', function () { expect($this->store->all())->toBe([]) ->and($this->store->isBlocked('203.0.113.10'))->toBeFalse(); }); it('blocks and unblocks an ip', function () { $this->store->block('203.0.113.10', 'test'); expect($this->store->isBlocked('203.0.113.10'))->toBeTrue() ->and($this->store->all()['203.0.113.10']['reason'])->toBe('test'); expect($this->store->unblock('203.0.113.10'))->toBeTrue() ->and($this->store->unblock('203.0.113.10'))->toBeFalse() ->and($this->store->isBlocked('203.0.113.10'))->toBeFalse(); }); it('supports ipv6', function () { $this->store->block('2001:db8::1'); expect($this->store->isBlocked('2001:db8::1'))->toBeTrue(); }); it('rejects invalid ips', function () { $this->store->block('not-an-ip'); })->throws(InvalidArgumentException::class); it('persists to the json file and is visible to other instances', function () { $this->store->block('198.51.100.20'); $other = new BadIpStore($this->store->path()); expect($other->isBlocked('198.51.100.20'))->toBeTrue() ->and(json_decode(file_get_contents($this->store->path()), true)['ips'])->toHaveKey('198.51.100.20'); }); it('picks up changes made by another process', function () { expect($this->store->isBlocked('192.0.2.30'))->toBeFalse(); (new BadIpStore($this->store->path()))->block('192.0.2.30'); expect($this->store->isBlocked('192.0.2.30'))->toBeTrue(); }); it('expires blocks with a ttl', function () { CarbonImmutable::setTestNow('2026-11-11 20:00:00'); $this->store->block('203.0.113.10', null, 60); expect($this->store->isBlocked('203.0.113.10'))->toBeTrue(); CarbonImmutable::setTestNow('2026-11-11 20:01:01'); expect($this->store->isBlocked('203.0.113.10'))->toBeFalse() ->and($this->store->all())->toBe([]); }); it('prunes expired entries', function () { CarbonImmutable::setTestNow('2026-11-11 20:00:00'); $this->store->block('203.0.113.10', null, 60); $this->store->block('198.51.100.20'); CarbonImmutable::setTestNow('2026-11-11 21:00:00'); expect($this->store->prune())->toBe(1) ->and(array_keys($this->store->all()))->toBe(['198.51.100.20']); }); it('clears all entries', function () { $this->store->block('203.0.113.10'); $this->store->clear(); expect($this->store->all())->toBe([]); }); it('treats a corrupt file as empty and logs a warning', function () { Log::spy(); @mkdir(dirname($this->store->path()), 0755, true); file_put_contents($this->store->path(), '{not json'); expect($this->store->all())->toBe([]); Log::shouldHaveReceived('warning')->once(); });