Completed
Pull Request — master (#402)
by Alejandro
05:51
created

LocateVisitsCommandTest   A

Complexity

Total Complexity 10

Size/Duplication

Total Lines 198
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
wmc 10
eloc 107
dl 0
loc 198
rs 10
c 0
b 0
f 0

8 Methods

Rating   Name   Duplication   Size   Complexity  
A localhostAndEmptyAddressesAreIgnored() 0 31 2
A noActionIsPerformedIfLockIsAcquired() 0 19 1
A errorWhileLocatingIpIsDisplayed() 0 25 1
A setUp() 0 23 1
A provideParams() 0 4 1
A showsProperMessageWhenGeoLiteUpdateFails() 0 25 2
A provideIgnoredAddresses() 0 5 1
A allPendingVisitsAreProcessed() 0 26 1
1
<?php
2
declare(strict_types=1);
3
4
namespace ShlinkioTest\Shlink\CLI\Command\Visit;
5
6
use PHPUnit\Framework\TestCase;
7
use Prophecy\Argument;
8
use Prophecy\Prophecy\ObjectProphecy;
9
use Shlinkio\Shlink\CLI\Command\Visit\LocateVisitsCommand;
10
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
11
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdaterInterface;
12
use Shlinkio\Shlink\Common\Exception\WrongIpException;
13
use Shlinkio\Shlink\Common\IpGeolocation\IpApiLocationResolver;
14
use Shlinkio\Shlink\Common\IpGeolocation\Model\Location;
15
use Shlinkio\Shlink\Common\Util\IpAddress;
16
use Shlinkio\Shlink\Core\Entity\ShortUrl;
17
use Shlinkio\Shlink\Core\Entity\Visit;
18
use Shlinkio\Shlink\Core\Entity\VisitLocation;
19
use Shlinkio\Shlink\Core\Model\Visitor;
20
use Shlinkio\Shlink\Core\Service\VisitService;
21
use Symfony\Component\Console\Application;
22
use Symfony\Component\Console\Output\OutputInterface;
23
use Symfony\Component\Console\Tester\CommandTester;
24
use Symfony\Component\Lock;
25
26
use function array_shift;
27
use function sprintf;
28
29
class LocateVisitsCommandTest extends TestCase
30
{
31
    /** @var CommandTester */
32
    private $commandTester;
33
    /** @var ObjectProphecy */
34
    private $visitService;
35
    /** @var ObjectProphecy */
36
    private $ipResolver;
37
    /** @var ObjectProphecy */
38
    private $locker;
39
    /** @var ObjectProphecy */
40
    private $lock;
41
    /** @var ObjectProphecy */
42
    private $dbUpdater;
43
44
    public function setUp(): void
45
    {
46
        $this->visitService = $this->prophesize(VisitService::class);
47
        $this->ipResolver = $this->prophesize(IpApiLocationResolver::class);
48
        $this->dbUpdater = $this->prophesize(GeolocationDbUpdaterInterface::class);
49
50
        $this->locker = $this->prophesize(Lock\Factory::class);
51
        $this->lock = $this->prophesize(Lock\LockInterface::class);
52
        $this->lock->acquire()->willReturn(true);
53
        $this->lock->release()->will(function () {
54
        });
55
        $this->locker->createLock(Argument::type('string'))->willReturn($this->lock->reveal());
56
57
        $command = new LocateVisitsCommand(
58
            $this->visitService->reveal(),
59
            $this->ipResolver->reveal(),
60
            $this->locker->reveal(),
61
            $this->dbUpdater->reveal()
62
        );
63
        $app = new Application();
64
        $app->add($command);
65
66
        $this->commandTester = new CommandTester($command);
67
    }
68
69
    /** @test */
70
    public function allPendingVisitsAreProcessed(): void
71
    {
72
        $visit = new Visit(new ShortUrl(''), new Visitor('', '', '1.2.3.4'));
73
        $location = new VisitLocation(Location::emptyInstance());
74
75
        $locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will(
76
            function (array $args) use ($visit, $location) {
77
                $firstCallback = array_shift($args);
78
                $firstCallback($visit);
79
80
                $secondCallback = array_shift($args);
81
                $secondCallback($location, $visit);
82
            }
83
        );
84
        $resolveIpLocation = $this->ipResolver->resolveIpLocation(Argument::any())->willReturn(
85
            Location::emptyInstance()
86
        );
87
88
        $this->commandTester->execute([
89
            'command' => 'visit:process',
90
        ]);
91
        $output = $this->commandTester->getDisplay();
92
93
        $this->assertStringContainsString('Processing IP 1.2.3.0', $output);
94
        $locateVisits->shouldHaveBeenCalledOnce();
95
        $resolveIpLocation->shouldHaveBeenCalledOnce();
96
    }
97
98
    /**
99
     * @test
100
     * @dataProvider provideIgnoredAddresses
101
     */
102
    public function localhostAndEmptyAddressesAreIgnored(?string $address, string $message): void
103
    {
104
        $visit = new Visit(new ShortUrl(''), new Visitor('', '', $address));
105
        $location = new VisitLocation(Location::emptyInstance());
106
107
        $locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will(
108
            function (array $args) use ($visit, $location) {
109
                $firstCallback = array_shift($args);
110
                $firstCallback($visit);
111
112
                $secondCallback = array_shift($args);
113
                $secondCallback($location, $visit);
114
            }
115
        );
116
        $resolveIpLocation = $this->ipResolver->resolveIpLocation(Argument::any())->willReturn(
117
            Location::emptyInstance()
118
        );
119
120
        $this->commandTester->execute([
121
            'command' => 'visit:process',
122
        ], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]);
123
124
        $output = $this->commandTester->getDisplay();
125
        $this->assertStringContainsString($message, $output);
126
        if (empty($address)) {
127
            $this->assertStringNotContainsString('Processing IP', $output);
128
        } else {
129
            $this->assertStringContainsString('Processing IP', $output);
130
        }
131
        $locateVisits->shouldHaveBeenCalledOnce();
132
        $resolveIpLocation->shouldNotHaveBeenCalled();
133
    }
134
135
    public function provideIgnoredAddresses(): iterable
136
    {
137
        yield 'with empty address' => ['', 'Ignored visit with no IP address'];
138
        yield 'with null address' => [null, 'Ignored visit with no IP address'];
139
        yield 'with localhost address' => [IpAddress::LOCALHOST, 'Ignored localhost address'];
140
    }
141
142
    /** @test */
143
    public function errorWhileLocatingIpIsDisplayed(): void
144
    {
145
        $visit = new Visit(new ShortUrl(''), new Visitor('', '', '1.2.3.4'));
146
        $location = new VisitLocation(Location::emptyInstance());
147
148
        $locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will(
149
            function (array $args) use ($visit, $location) {
150
                $firstCallback = array_shift($args);
151
                $firstCallback($visit);
152
153
                $secondCallback = array_shift($args);
154
                $secondCallback($location, $visit);
155
            }
156
        );
157
        $resolveIpLocation = $this->ipResolver->resolveIpLocation(Argument::any())->willThrow(WrongIpException::class);
158
159
        $this->commandTester->execute([
160
            'command' => 'visit:process',
161
        ], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]);
162
163
        $output = $this->commandTester->getDisplay();
164
165
        $this->assertStringContainsString('An error occurred while locating IP. Skipped', $output);
166
        $locateVisits->shouldHaveBeenCalledOnce();
167
        $resolveIpLocation->shouldHaveBeenCalledOnce();
168
    }
169
170
    /** @test */
171
    public function noActionIsPerformedIfLockIsAcquired()
172
    {
173
        $this->lock->acquire()->willReturn(false);
174
175
        $locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will(function () {
176
        });
177
        $resolveIpLocation = $this->ipResolver->resolveIpLocation(Argument::any())->willReturn([]);
178
179
        $this->commandTester->execute([
180
            'command' => 'visit:process',
181
        ], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]);
182
        $output = $this->commandTester->getDisplay();
183
184
        $this->assertStringContainsString(
185
            sprintf('There is already an instance of the "%s" command', LocateVisitsCommand::NAME),
186
            $output
187
        );
188
        $locateVisits->shouldNotHaveBeenCalled();
189
        $resolveIpLocation->shouldNotHaveBeenCalled();
190
    }
191
192
    /**
193
     * @test
194
     * @dataProvider provideParams
195
     */
196
    public function showsProperMessageWhenGeoLiteUpdateFails(bool $olderDbExists, string $expectedMessage): void
197
    {
198
        $locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will(function () {
199
        });
200
        $checkDbUpdate = $this->dbUpdater->checkDbUpdate(Argument::cetera())->will(
201
            function (array $args) use ($olderDbExists) {
202
                [$mustBeUpdated, $handleProgress] = $args;
203
204
                $mustBeUpdated($olderDbExists);
205
                $handleProgress(100, 50);
206
207
                throw GeolocationDbUpdateFailedException::create($olderDbExists);
208
            }
209
        );
210
211
        $this->commandTester->execute([]);
212
        $output = $this->commandTester->getDisplay();
213
214
        $this->assertStringContainsString(
215
            sprintf('%s GeoLite2 database...', $olderDbExists ? 'Updating' : 'Downloading'),
216
            $output
217
        );
218
        $this->assertStringContainsString($expectedMessage, $output);
219
        $locateVisits->shouldHaveBeenCalledTimes((int) $olderDbExists);
220
        $checkDbUpdate->shouldHaveBeenCalledOnce();
221
    }
222
223
    public function provideParams(): iterable
224
    {
225
        yield [true, '[Warning] GeoLite2 database update failed. Proceeding with old version.'];
226
        yield [false, 'GeoLite2 database download failed. It is not possible to locate visits.'];
227
    }
228
}
229